Pilotiq
DocsGitHub

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 SyncConfig

For 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.XmlFragment per Tiptap RichTextField — keyed by the field's name. ProseMirror state binds via y-prosemirror; cursors propagate via @tiptap/extension-collaboration-caret. Two RichTextFields on one record (e.g. body + content) write to two fragments inside the same ydoc.
  • Y.Text per plain-text field — TextField / TextareaField / EmailField / SlugField / MarkdownField. Character-level CRDT. Each field gets a top-level Y.Text keyed by field name; edits emit insert / delete / replace deltas. No flicker on simultaneous typing.
  • Y.Map named form-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 compositionapplyDelta 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-dataY.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 nested Y.Text.
  • row-orderY.Map<arrayName, Y.Array<rowId>>. Reorder = pure rowId-string moves in the Y.Array. Row Y.Maps stay put in row-data; nested Y.Text content 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.relationship PK switch on save — when a new row's UUID __id becomes a DB PK on save, the row's CRDT identity changes. F.5b doesn't yet reconcile this; rebuild on subscribeRows snapshot if any __id changed.
  • Tiptap fields inside rowsY.XmlFragment row-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 focusField awareness state doesn't leak this field's name to peers (so other users won't see "Sleman is reviewing the passwordReset field").
TextField.make('internalNotes').collab(false)   // local-only scratch space

Use 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() persistence

Five 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 title in window A → window B reflects character-by-character (Y.Text, no flicker even with simultaneous typing).
  • Change status in 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 title and type simultaneously → both characters appear on both sides without overwriting; local cursor stays anchored.
  • Focus title in A → small colored dot appears next to title in 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 the SyncDocument table).

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.

  • pilotiq-pro/docs/development.md — dev workflow + @rudderjs/sync transport 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 + onFirstConnect seed).
  • pilotiq/docs/plans/collab-f5-row-identity.md (in pilotiq repo) — F.5 plan: hybrid row-data + row-order shape + per-row Y.Text.
  • pilotiq/docs/plans/collab-opt-in.md (in pilotiq repo) — Per-Resource opt-in plan (static collab).