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 text-shaped field (plain inputs and rich text alike) attaches via the Tiptap Collaboration extension against its own Y.XmlFragment, every other controlled 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. A persistence adapter keeps Y.Doc state across server restarts — syncDatabase() rides any rudder 'db' adapter (native engine, Drizzle); Prisma apps use @rudderjs/sync's syncPrisma() instead.
// config/sync.ts (already present in `pilotiq-pro/playground` — copy from there)
import { syncDatabase } from '@pilotiq-pro/collab/server'
import type { SyncConfig } from '@rudderjs/sync'
export default {
path: '/ws-sync',
persistence: syncDatabase(),
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 two CRDT surfaces:
Y.XmlFragmentper text-shaped field — both TiptapRichTextFieldand every plain-text field (TextField / TextareaField / EmailField / SlugField / MarkdownField). Each is keyed by the field'snameand mounted by@pilotiq/tiptap'sCollabTextRenderer: ProseMirror state binds viay-prosemirror; cursors propagate via@tiptap/extension-collaboration-caret. Selections anchor toY.RelativePosition, so concurrent inserts and mid-word remote edits never scramble the local cursor. Two text fields on one record (e.g.title+body) write to two fragments inside the same ydoc.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.XmlFragment |
Tiptap-backed (Phase D). One fragment per field via CollabTextRenderer; cursor anchors to Y.RelativePosition. |
MarkdownField |
Y.XmlFragment |
Same — collapses to the plain-text Tiptap editor under collab; markdown syntax typed by hand, preview tab still works. |
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.XmlFragment |
Tiptap-backed, keyed ${arrayName}.${rowId}.${fieldName} at the doc root — survives reorder. See Per-row CRDT. |
CodeEditor (@pilotiq/codemirror) |
Y.Text |
y-codemirror.next binding registered by this plugin via pilotiq's registerCollabCodeExtensions (the CodeMirror sibling of the Tiptap extension slot — the adapter carries no yjs deps itself). Row leaves key ${arrayName}.${rowId}.${fieldName}; renameRow rekeys on PK-switch. |
#Plain-text inputs (Tiptap-backed, Phase D)
Every text-shaped field is owned by a Tiptap Y.XmlFragment keyed by the field name — the same path RichTextField uses, mounted by @pilotiq/tiptap's CollabTextRenderer (a plain-text Tiptap editor for non-rich fields). The renderer seeds its own fragment client-side from the SSR-rendered defaultValue on first attach, then y-prosemirror handles every keystroke. Cursor position survives concurrent and mid-word remote edits because selections anchor to Y.RelativePosition, not string offsets.
This superseded the earlier per-field
Y.Text+computeDelta+preserveCursorpath (Phase F.6) — string-offset diffs couldn't anchor a cursor across concurrent edits. The binding (formCollabBinding) and the server seeder (seedDocFromRecord) now deliberately skip text fields so they never allocate aY.Textat a key theY.XmlFragmentalready claims (a constructor collision that breaks y-prosemirror). Seecollab-text-tiptap-backed-phase-d.md.
Y.Doc (per record)
├── form-data : Y.Map<string, *> ← Toggle / Select / Date / Slider / Color / KeyValue / etc.
│
├── title : Y.XmlFragment ← TextField (Tiptap-backed)
├── excerpt : Y.XmlFragment ← TextField
├── slug : Y.XmlFragment ← SlugField
│
├── body : Y.XmlFragment ← Tiptap RichTextField
├── content : Y.XmlFragment ← Tiptap RichTextField
│
├── articles.<rowId>.headline : Y.XmlFragment ← row text leaf, keyed ${arrayName}.${rowId}.${fieldName}
│
├── row-data : Y.Map<arrayName, ← Repeater/Builder row Y.Maps keyed by stable rowId
│ Y.Map<rowId, Each row Y.Map holds scalars as plain values;
│ Y.Map<field, *>>> text leaves live as XmlFragments at the doc root (above), not nested
└── row-order : Y.Map<arrayName, ← row order as an array of rowIds; reorder = string moves
Y.Array<rowId>>
awareness ← Yjs awareness (presence + focus + cursors)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 the XmlFragment binding for that field; it stays purely local.
For fields where even the collab fragment 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 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, plus a Tiptap Y.XmlFragment per text leaf at the doc root:
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 leaves are not stored in the row Y.Map — they're TiptapY.XmlFragments at the doc root, keyed${arrayName}.${rowId}.${fieldName}so they survive reorder.row-order—Y.Map<arrayName, Y.Array<rowId>>. Reorder = pure rowId-string moves in the Y.Array. Row Y.Maps stay put inrow-data; the rowId-keyed text fragments stay attached to their row across any reorder.
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 (scalars only) in row-data, pushes rowId onto row-order. Text leaves get their Y.XmlFragment lazily on first editor attach. |
| 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. The row's text fragments are left orphan at the doc root (cost-free; nothing reads them). |
| Local reorder (DnD) | Rewrites the row-order Y.Array; row Y.Map identities + rowId-keyed text fragments preserved. |
| Local text edit inside a row | Routes through the row's Y.XmlFragment via CollabTextRenderer — same Tiptap path as top-level text fields. |
| Remote add / remove / move | Renderer subscribes to `RowsEvent { add |
The text-field allowlist inside rows is the same as top-level (text / textarea / email / slug / markdown, per TEXT_FIELD_TYPES). .collab(false) on the row's inner field opts that leaf out (stays local).
#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 deeper segments; nested array text fields stay on whole-string 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. Reconciled by therenameRowpath (seerepeater-relationship-pk-switch.md).- 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.XmlFragment per text field | Edit page | TextField / TextareaField / EmailField / SlugField / MarkdownField, Tiptap-backed. |
| 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)
└─ syncDatabase() 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 (TiptapY.XmlFragment, 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 (y-prosemirrorY.RelativePosition). - 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 (row
Y.XmlFragmentkeyed by${arrayName}.${rowId}.${fieldName}). - Drag-reorder rows in A → B reflects the new order; text typed inside each row stays attached to that row (fragment keyed by rowId).
- Reload either tab — values survive (Y.Map + text/row
Y.XmlFragments + row Y.Maps persisted bysyncDatabase()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.
Text fields (TextField, TextareaField, RichTextField, etc.) — owned by a Tiptap Y.XmlFragment, never seeded into a Y.Map or Y.Text by the binding. CollabTextRenderer seeds the fragment client-side from the SSR-rendered defaultValue on first attach (its trySeed branch); y-prosemirror's ProseMirror seed is idempotent across concurrent first-mounters, so there's no duplicate-content race. The fresh-record race the old Phase F.6 Y.Text path worried about is additionally closed server-side: syncOnFirstConnect (shipped; wired in the playground's config/sync.ts, paired with registerCollabHydrator in bootstrap/providers.ts) is a single server-elected seeder that fills empty CRDT slots from the DB row on the first websocket attach, race-free by construction, and skips text fields (they're owned by the XmlFragment).
#How does the binding decide XmlFragment vs Y.Map?
By fieldType. The text-shaped allowlist (text / textarea / email / slug / markdown, the TEXT_FIELD_TYPES set) lives inside @pilotiq-pro/collab and is shared by the client formCollabBinding and the server seedDocFromRecord so both sides agree. Text-shaped fields are owned by Tiptap's Y.XmlFragment (registered via registerCollabExtensions + CollabTextRenderer); everything else controlled flows through the form-data Y.Map. The binding/seeder deliberately skip text fields so neither allocates a Y.Text/Y.Map slot at a key the XmlFragment already claims. 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 the form-data Y.Map. Text fields are owned by their Tiptap Y.XmlFragment, so server-derived text re-renders through the editor's normal value path rather than a Y.Map write.
Known limitation — if a user is typing a field at the moment the server returns a derived value for the same field, the server write can clobber in-flight input (LWW race for Y.Map fields). Mitigation: .live(false) on fields that don't need server normalisation.
#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. Row text leaves are now TiptapY.XmlFragments (seecollab-row-text-tiptap-backed.md).pilotiq-pro/docs/plans/collab-text-tiptap-backed-phase-d.md— top-level text fields → TiptapY.XmlFragment(superseded the F.6Y.Textpath).pilotiq-pro/docs/plans/collab-row-text-tiptap-backed.md— row text leaves → TiptapY.XmlFragmentkeyed by${arrayName}.${rowId}.${fieldName}.pilotiq/docs/plans/collab-opt-in.md(in pilotiq repo) — Per-Resource opt-in plan (static collab).