Realtime collab
@pilotiq-pro/collab adds realtime multi-user collaboration to a pilotiq panel — one Y.Doc per record, every field on the page joins via the same WebSocket. Two users editing the same record see each other's changes within ~100ms, presence chips next to the focused field, and Tiptap cursors inside rich-text editors.
The collab integration plugs into pilotiq's open-core registry slots (added in @pilotiq/[email protected]); pilotiq core itself stays Yjs-free.
#Quick start
// app/Pilotiq/AdminPanel.ts
import { Pilotiq } from '@pilotiq/pilotiq'
import { collab } from '@pilotiq-pro/collab'
export const admin = Pilotiq.make('Admin')
.path('/admin')
.plugins([
collab({ wsPath: '/ws-sync' }), // default — see Transport below
])
.resources([…])// app/Resources/PostResource.ts
import { Resource } from '@pilotiq/pilotiq'
export class PostResource extends Resource {
static override model = Post
static override collab = true // ← per-Resource opt-in; required since 0.9.0
}/* app's global stylesheet */
@import "@pilotiq-pro/collab/styles/caret.css"; /* Tiptap remote carets */
@import "@pilotiq-pro/collab/styles/presence.css"; /* per-field presence chips */That's it. Every record-edit page on an opted-in Resource (${base}/${slug}/:id/edit) auto-wraps in <RecordCollabRoom>, every Tiptap field attaches via the Collaboration extension, every controlled non-Tiptap field syncs via a shared Y.Map, and every focused field broadcasts presence over Yjs awareness.
#Transport
wsPath defaults to '/ws-sync' — matches @rudderjs/sync's default endpoint, so RudderJS apps get realtime collab with zero extra plumbing. The sync layer ships with the framework: it speaks the y-websocket wire format (lib0/y-protocols) and handles WebSocket upgrades on the same Hono server as the rest of the app. syncPrisma() persists Y.Doc state across server restarts.
// config/sync.ts (already present in `pilotiq-pro/playground` — copy from there)
import { syncPrisma } from '@rudderjs/sync'
import type { SyncConfig } from '@rudderjs/sync'
export default {
path: '/ws-sync',
persistence: syncPrisma(),
providers: ['websocket', 'indexeddb'],
} satisfies SyncConfigFor deployments that need a different WS endpoint (separate Hocuspocus server, managed Yjs provider, custom WS handler), pass it through:
collab({ wsPath: 'wss://collab.example.com' })#What syncs (and how)
Every collab-eligible field on a record edits one shared Y.Doc. The doc holds three different Yjs surfaces:
Y.XmlFragmentper TiptapRichTextField— keyed by the field'sname. ProseMirror state binds viay-prosemirror; cursors propagate via@tiptap/extension-collaboration-caret. TwoRichTextFields on one record (e.g.body+content) write to two fragments inside the same ydoc.Y.Textper plain-text field —TextField / TextareaField / EmailField / SlugField / MarkdownField. Character-level CRDT. Each field gets a top-levelY.Textkeyed by field name; edits emitinsert / delete / replacedeltas. No flicker on simultaneous typing.Y.Mapnamedform-data— every other controlled field (Toggle / Select / Date / Color / KeyValue / …) writes its value to one top-level key. Per-key LWW (last writer wins) — the right semantics for discrete state.
#Field-type semantics
| Field family | Yjs type | Notes |
|---|---|---|
Toggle, Checkbox, Radio, Color |
Y.Map (LWW) |
Single value per key — clean LWW semantics. |
Select (single / multi), ToggleButtons |
Y.Map (LWW) |
Multi as string[]; full replace per edit. |
Number, Slider, Date, DateTime |
Y.Map (LWW) |
Same. |
TextField, TextareaField, EmailField, SlugField |
Y.Text |
Character-level CRDT (Phase F.6). One Y.Text per field; no flicker. |
MarkdownField |
Y.Text |
Same — toolbar splices (bold/italic/list) ride the delta path too. |
KeyValue, TagsInput, FileUpload |
Y.Map (LWW) |
Object / array values replace wholesale. |
Hidden |
Y.Map (LWW) |
Programmatic writes propagate too. |
RichTextField (Tiptap) |
Y.XmlFragment |
Character-level CRDT via y-prosemirror. Cursor presence via Caret. |
Repeater, Builder rows |
Y.Map + Y.Array |
Stable rowId-keyed Y.Maps in row-data; row order in row-order Y.Array. Concurrent inserts both survive (Phase F.5b). See Per-row CRDT below. |
| Text leaves inside Repeater/Builder rows | Y.Text |
Per-row Y.Text allocated under the row's Y.Map — character-level CRDT inside rows (Phase F.5c). |
CodeEditor (@pilotiq/codemirror) |
deferred | Will slot in via y-codemirror.next (separate plan). |
#Plain-text inputs (Phase F.6)
Each text-shaped field allocated by the binding gets its own Y.Text keyed by the field name. The renderer (BoundTextInput inside TextLikeInput / the textarea path in MarkdownInput) reads binding.read() for the initial value, observes remote edits, and emits insert / delete / replace deltas on local keystrokes. Cursor position survives both — local edits never jump on remote inserts before the cursor (best-effort heuristic; perfect anchoring lands when in-input remote-caret rendering ships).
Y.Doc (per record)
├── form-data : Y.Map<string, *> ← Toggle / Select / Date / Slider / Color / KeyValue / etc.
│
├── title : Y.Text ← TextField — character-level CRDT
├── excerpt : Y.Text ← TextField
├── slug : Y.Text ← SlugField
│
├── body : Y.XmlFragment ← Tiptap RichTextField
├── content : Y.XmlFragment ← Tiptap RichTextField
│
├── row-data : Y.Map<arrayName, ← Repeater/Builder row Y.Maps keyed by stable rowId
│ Y.Map<rowId, Each row Y.Map can hold scalars + nested Y.Text leaves
│ Y.Map<field, *>>>
└── row-order : Y.Map<arrayName, ← row order as an array of rowIds; reorder = string moves
Y.Array<rowId>>
awareness ← Yjs awareness (presence + focus + cursors)IME composition — applyDelta is gated until compositionend so Chinese / Japanese / Korean input methods never emit ops for intermediate composing characters.
Mask — fields with TextField.mask('(999) 999-9999') fall back to LWW. Mask + character-CRDT is incompatible: peers would see raw keystrokes diverged from the local mask render. This is a deliberate carve-out, not a bug.
Opt out per field — .collab(false) (see below) suppresses Y.Text allocation for that field; it stays purely local.
For fields where even Y.Text shouldn't sync, opt out:
TextField.make('internalNotes').collab(false) // local-only scratch space#Per-row CRDT (Phase F.5)
Repeater and Builder fields sync row identity, order, and per-row text — concurrent inserts on two peers both survive, reorder preserves a row's character-level Y.Text content, and text typed inside a row merges character-by-character rather than under LWW.
The shape is a hybrid of two top-level Y.Maps inside the record's Y.Doc:
row-data—Y.Map<arrayName, Y.Map<rowId, Y.Map<fieldName, value>>>. Each row Y.Map is keyed by its stable__id(UUID for new rows, DB PK for relationship-backed rows) and never moves. Scalar fields live as plain values; text-shaped fields (per the F.6 allowlist) live as nestedY.Text.row-order—Y.Map<arrayName, Y.Array<rowId>>. Reorder = pure rowId-string moves in the Y.Array. Row Y.Maps stay put inrow-data; nestedY.Textcontent survives any reorder cleanly.
Liveblocks and Loro both use this shape for the same reason — Yjs has no native Y.Array move primitive, and a delete+insert workaround would destroy nested CRDT content on every reorder.
| Operation | Behavior |
|---|---|
| Local add row | Inserts row Y.Map in row-data, pushes rowId onto row-order. Y.Text leaves eager-seeded as empty in the same transact (originating peer only). |
| Concurrent add on two peers | Both rows survive — Y.Array tracks both inserts, neither overwrites. |
| Local remove row | Removes rowId from row-order, deletes row Y.Map from row-data, evicts cached Y.Text bindings for that row. |
| Local reorder (DnD) | Rewrites the row-order Y.Array; row Y.Map identities + nested Y.Text content preserved. |
| Local text edit inside a row | Routes through the per-row Y.Text's minimal-diff path (same posture as top-level F.6). |
| Remote add / remove / move | Renderer subscribes to `RowsEvent { add |
The text-field allowlist inside rows is the same as top-level F.6: text / textarea / email / slug / markdown. .collab(false) on the row's inner field opts that leaf out (stays LWW per-row).
#Legacy-shape migration
Forms previously synced before F.5b stored their Repeater values as opaque JSON arrays under the top-level form-data Y.Map. On first connect after the F.5b upgrade, migrateLegacyArrays lifts every form-data[arrayName] that looks like an array of {__id, …} objects into the new row-data / row-order shape. Idempotent — skips if row-data[arrayName] already exists. Rows without a string __id are dropped defensively.
#v1 limitations
- Nested Repeaters / Builders (
articles.0.comments.0.body) — the dotted-path parser rejects 5+ segments; nested array text fields stay LWW. Out of scope for v1. Repeater.relationshipPK switch on save — when a new row's UUID__idbecomes a DB PK on save, the row's CRDT identity changes. F.5b doesn't yet reconcile this; rebuild onsubscribeRowssnapshot if any__idchanged.- Tiptap fields inside rows —
Y.XmlFragmentrow-id addressing isn't wired. A row's Tiptap mounts as its own room-scoped editor (works), but no row-keyed CRDT identity. - Cross-form row identity collision — same record edited by two forms simultaneously could open two row-arrays under the same name. Same posture as top-level
form-data(single map per room).
#Per-Resource opt-in: static collab
Collab is gated per Resource since 0.9.0. Registering the collab() plugin is a prerequisite — not an activator. Each Resource that should sync must opt in:
class PostResource extends Resource {
static override collab = true // edit page, presence, all defaults
}
class CommentResource extends Resource {
static override collab = { // explicit form
pages: ['edit'], // 'edit' only — 'view' / custom pages not yet supported
presence: true, // presence chip rail
}
}
class AuditLogResource extends Resource {
static override collab = false // default — no collab wrapper mounted
}panelInfo() emits a sparse recordCollab: Record<URLSlug, ResourceCollabConfig> map; <RecordWrapperGate> consults it per request before mounting the registered wrapper. Resources without an opt-in fall through with no collab overhead — no Y.Doc, no WS connection on visit, no awareness.
| Per-Resource feature | Surface | Notes |
|---|---|---|
static collab = true |
Edit page | Implies { pages: ['edit'], presence: true }. |
static collab = { pages, presence } |
Edit page | Object form for explicit per-feature opt-in. |
| Y.Map form-data sync | Edit page | All controlled non-text fields. |
| Y.Text per text field | Edit page (F.6) | TextField / TextareaField / EmailField / SlugField / MarkdownField. |
| Y.XmlFragment per Tiptap field | Edit page | RichTextField via y-prosemirror. |
| Y.Array / Y.Map row identity | Edit page (F.5) | Repeater / Builder rows — see Per-row CRDT. |
| Presence chips | Edit page (F4) | Awareness-driven dot rail. |
| Custom panel pages (Dashboard/Settings) | — | Deferred — needs a different wrapper shape (literal room). |
| List / view / create pages | — | Not wrapped — no collab semantics today. |
The static collab = true shorthand is the recommended default. Migration from pre-0.9.0 (panel-wide collab) is a two-line change per Resource.
#Per-field opt-out: .collab(false)
Available on every Field subclass — pilotiq base method since 0.8.0. Marks the field as fully invisible to the collab layer:
- No value sync — local edits write to React state only, never to the
Y.Map. - No presence chip — the focused-by-others rail doesn't render next to the label.
- No focus broadcast — the local user's
focusFieldawareness state doesn't leak this field's name to peers (so other users won't see "Sleman is reviewing thepasswordResetfield").
TextField.make('internalNotes').collab(false) // local-only scratch spaceUse it for:
- Sensitive scratch space (
internalNotes,auditTrail,passwordReset). - Fields where the LWW footgun would surprise users (rapid typing in long-form text inputs).
- Computed / read-only fields that shouldn't broadcast presence either.
#Presence chips
Every controlled field shows a small colored-dot rail next to its label for every remote user currently focused on it (Phase F4). Peer color + display name come from provider.awareness.setLocalStateField('user', { name, color }) — set by useRecordCollabRoom per session.
┌─────────────────────────────────┐
│ Title ●● [ ] │ ← Alex + Sam are focused on `title`
│ │
│ Status ● [ Draft ▾] │ ← Maia is focused on `status`
│ │
│ Body [ rich text… ] │ ← Tiptap cursors inside the editor
└─────────────────────────────────┘Tooltip on each dot surfaces the user's display name. The chip rail vanishes on blur.
#Architecture overview
Browser tab (per user)
─────────────────────────
AppShell
└─ <CollabProvider wsPath> ← layout-provider slot
└─ <RecordWrapperGate> ← parses /…/:id/edit
└─ <RecordCollabRoom> ← opens Y.Doc + WS + IDB
└─ <CollabRoomContext> ← { ydoc, provider }
└─ Page tree
└─ <FormRenderer>
└─ <FormStateProvider> ← reads useCollabRoom()
├─ FormCollabBinding (Y.Map form-data)
└─ Each <FieldShell>
├─ <FieldPresenceChip> ← reads awareness
├─ onFocusCapture → fieldFocusReporter
└─ <TextInput / Toggle / Tiptap / …>
── WS upgrade ──→ @rudderjs/sync server (Hono)
└─ syncPrisma() persistenceFive registry slots in @pilotiq/pilotiq/react are filled at plugin boot:
| Slot | Pro impl from @pilotiq-pro/collab |
|---|---|
registerCollabExtensions |
Tiptap [Collaboration, CollaborationCaret] factory |
registerRecordWrapper |
<RecordCollabRoom roomName="${slug}/${id}"> |
registerFormCollabBinding |
formCollabBinding — Y.Map adapter for form-data |
registerFieldPresenceComponent |
<FieldPresenceChip> — awareness-driven dot rail |
registerFieldFocusReporter |
fieldFocusReporter — writes focusField on focus / blur |
Plus the panel.layoutProvider(...) mount that auto-wraps <CollabProvider wsPath> so consumers don't edit the auto-generated pages/+Layout.tsx.
#Smoke test (playground)
Two windows on the same record:
http://localhost:3002/admin/posts/<id>/edit- Type into
titlein window A → window B reflects character-by-character (Y.Text, no flicker even with simultaneous typing). - Change
statusin A → B's dropdown updates (Y.Map LWW). - Edit
body(RichTextField) — Tiptap cursors propagate; multi-user typing merges via y-prosemirror. - Position cursors at different points in the same
titleand type simultaneously → both characters appear on both sides without overwriting; local cursor stays anchored. - Focus
titlein A → small colored dot appears next totitlein B; blur clears it. - Click Add row on a Repeater in A → row appears in B. Click Add simultaneously in both → both rows survive (F.5b).
- Type into a row text field in A → B reflects character-by-character (F.5c per-row Y.Text).
- Drag-reorder rows in A → B reflects the new order; text typed inside each row stays attached to that row.
- Reload either tab — values survive (Y.Map + Y.Text + row Y.Maps persisted by
syncPrisma()to theSyncDocumenttable).
DevTools → Network → WS: exactly one WebSocket connection per tab, regardless of how many collab fields the form hosts.
#FAQ
#Why one map per room, not per form?
Pilotiq's auto-generated formId is a monotonic per-process counter (form-1, form-2, …) that ticks on every server render. Two windows opening the same record-edit page get different formIds — partitioning the Y.Map by formId would put them in different maps and never sync.
The v1 binding uses a single form-data map per room. Multi-form pages (record-edit + action modal on the same record) share the map under LWW per key; collisions on field names across forms-on-the-same-record are rare in practice, and the existing Form.make().formId('stable-id') pinning hook is the existing workaround for any real collision.
#What happens if two users open a fresh record simultaneously?
Y.Map fields (Toggle, Select, Date, etc.) — the binding's initial seed is idempotent (!ymap.has(key) per key). Both clients race to write the DB-derived defaults; Yjs's per-key LWW resolves per key. In the common case both writers carry the same DB snapshot, so the merged result is identical to either side's view — no visible flicker.
Y.Text fields (TextField, TextareaField, etc.) — there is no client-side seed. Unlike Y.Map.set(k, v), Y.Text.insert(0, str) from two concurrent first-mounters produces two surviving inserts at position 0, duplicating the content on every fresh-page-load race. Instead, the renderer falls back to the SSR default for display while Y.Text is empty, and the first edit on any peer emits a replace 0..0 → typed-value delta — atomically populating Y.Text without a separate seed op.
The residual race: two peers typing simultaneously into an empty Y.Text both produce a "fill from empty" delta at position 0; Yjs merges these as concurrent inserts and both survive. In practice this requires both users to start typing within the same Y-sync roundtrip — rare for editorial workflows. A future Phase E will move the seed server-side (single server-elected seeder, race-free by construction) once @rudderjs/sync lands an onFirstConnect hook.
#How does the binding decide Y.Text vs Y.Map?
By fieldType at binding construction. The allowlist (text / textarea / email / slug / markdown) lives inside @pilotiq-pro/collab — pilotiq core asks the binding for every top-level field name and stashes only non-null answers. Decisions are made once when the <RecordCollabRoom> mounts; structural changes from live() re-resolves don't re-walk (dynamic field add/remove is rare and an F-followup if a consumer asks). Per-field .collab(false) always wins over the allowlist.
#What about .live() fields?
Server-derived values (e.g. auto-slug from title) propagate to peers automatically: non-text values land in Y.Map; text values get a replace delta computed from current Y.Text contents → server value and applied in a single Yjs transaction. Same-value writes short-circuit (the common case after a local edit's applyDelta has already updated the Y.Text).
Known limitation — if a user is typing a text field at the moment the server returns a derived value for the same field, the replace delta clobbers in-flight characters. Same race exists for non-text fields under LWW. Mitigation: .live(false) on text fields that don't need server normalisation. A "directly-edited since request" guard is filed for the next polish pass.
#Related
pilotiq-pro/docs/development.md— dev workflow +@rudderjs/synctransport notes.pilotiq-pro/docs/plans/collab-record-level-ydoc.md— Phases A–E plan (record-room API, Tiptap wiring, host registries, playground proof, server helper).pilotiq-pro/docs/plans/collab-form-fields.md— Phase F plan (form-level Y.Map, presence chips, the open questions table).pilotiq-pro/docs/plans/collab-ssr-hydration.md— SSR-from-Y.Doc arc (default-fill precedence +onFirstConnectseed).pilotiq/docs/plans/collab-f5-row-identity.md(in pilotiq repo) — F.5 plan: hybridrow-data+row-ordershape + per-row Y.Text.pilotiq/docs/plans/collab-opt-in.md(in pilotiq repo) — Per-Resource opt-in plan (static collab).