@pyreon/sync — API Reference
Generated from
sync'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see sync.
A local-first / collaborative sync layer for Pyreon. A synced value is a normal Signal (built via wrapSignal), so a remote change becomes one signal.set → one surgical fine-grained DOM update — never a VDOM re-render + diff. The engine-neutral CrdtAdapter seam keeps the reactive bridge engine-free; the real engine (raw Yjs) lives behind @pyreon/sync/yjs so importing the core never pulls in yjs. Covers offline persistence (IndexedDB), same-origin cross-tab + cross-device WebSocket transport, collaborative text + lists, and a Node/Bun relay with per-room/per-doc authz at @pyreon/sync/server. v1 syncs scalar map fields + collaborative Y.Text / Y.Array.
Features
syncedSignal / syncedStore — bind a Signal to a CRDT map entry; a synced signal is indistinguishable from a normal signal to the compiler and every effect
Engine-neutral CrdtAdapter / CrdtDoc / CrdtMap seam + an in-memory FakeCrdtAdapter for dependency-free unit tests
Real Yjs engine behind the @pyreon/sync/yjs subpath (yjs stays out of the core entry)
Offline persistence via IndexedDB (persistViaIndexedDB)
Same-origin cross-tab sync (connectViaBroadcastChannel) + cross-device WebSocket transport (connectViaWebSocket, auto-reconnect)
Collaborative text (syncedText / Y.Text) + lists (syncedList / Y.Array) with true positional merge — concurrent edits keep BOTH
Ephemeral presence + live cursors (syncedAwareness) over the Yjs awareness protocol — a separate, never-persisted channel; the relay is awareness-stateful so a new client sees existing peers instantly + a crashed client is purged on disconnect
Node/Bun relay server (createSyncServer) with a per-room/per-doc authorize gate + attach-to-existing-HTTP-server mode
Loop-free by construction: the observer applies every change; the transport never re-broadcasts a REMOTE-origin update (the same shared tag guards awareness across BOTH transports)
Clobber-free seed: syncedSignal's create-if-missing seed DEFERS until first sync when a transport is attached (WebSocketTransport.synced / whenSynced) — a fresh peer's default never races a peer's real value on a random-clientId tie-break (#2380)
Exports
| Symbol | Kind | Summary |
|---|---|---|
syncedSignal | function | Bind a Signal<T> to a single scalar entry in a CRDT map. |
syncedStore | function | Build a flat store of synced fields from a plain initial object — the ergonomic layer over syncedSignal. |
SyncedSignal | type | A Signal<T> bound to a CRDT entry. |
SyncedStore | type | A mapped type — each key of the initial object becomes a SyncedSignal of that field's type, plus a store-level `dispose( |
CrdtAdapter | type | The engine-neutral seam. |
LOCAL_ORIGIN | constant | Transaction-origin tag for a LOCAL write (a .set originating on this client). |
REMOTE_ORIGIN | constant | Transaction-origin tag for a REMOTE-applied update (received from a peer/relay). |
FakeCrdtAdapter | class | An in-memory, dependency-free CrdtAdapter for unit-testing synced stores without standing up a real engine. |
connectFakeDocs | function | Link two in-memory FakeCrdtDocs so a write to one propagates to the other — the test analog of a transport. |
createYjsDoc | function | Create a CrdtDoc backed by a real Yjs Y.Doc (or wrap an existing one). |
syncedText | function | Bind a Signal<string> to a Yjs Y.Text — a COLLABORATIVE string with character-level CRDT merge. |
syncedList | function | Bind a Signal<T[]> to a Yjs Y.Array — a COLLABORATIVE list with positional CRDT merge. |
syncedAwareness | function | Reactive EPHEMERAL presence — who's online + their live cursor — over the Yjs awareness protocol, a SEPARATE channel fro |
SyncedAwareness | type | The reactive presence handle from syncedAwareness. |
PeerState | type | One peer's presence entry: its awareness clientId (use it as the <For> key), its published state, and isLocal (w |
connectViaBroadcastChannel | function | Same-origin CROSS-TAB sync over BroadcastChannel — edits in one tab appear in another tab of the same origin, no server. |
connectViaWebSocket | function | Sync a YjsCrdtDoc to a relay over WebSocket — the CROSS-DEVICE transport. |
persistViaIndexedDB | function | Persist a YjsCrdtDoc to IndexedDB so edits survive a reload and the app works offline (thin wrapper over y-indexeddb). |
createSyncServer | function | Start a Node/Bun WebSocket relay that brokers Yjs sync between clients sharing a room. |
AuthorizeContext | type | Context passed to the relay's authorize hook: the room parsed from the URL path, the token query-string param (bro |
API
syncedSignal function
<T>(options: SyncedSignalOptions<T>) => SyncedSignal<T>Bind a Signal<T> to a single scalar entry in a CRDT map. The return value is a NORMAL signal (via wrapSignal — reads / _v / .direct all delegate), so the compiler's _bindText/_bindDirect fast paths and every effect treat it like any signal: a remote op becomes one base.set → one fine-grained DOM update. The update loop has a single writer — .set(v) writes ONLY the CRDT; the map observer is the one path that writes the base signal (for local AND remote commits); the local echo is an Object.is no-op.
Example
const title = syncedSignal({ doc, key: "title", initial: "Untitled" })
// <h1>{title()}</h1> — patches in place when any peer edits the title
title() // "Untitled" (reactive read)
title.set("Roadmap") // writes the CRDT; the observer drives the DOM update
title.dispose() // detach observer (auto on onCleanup inside a scope)Common mistakes
Calling
title(newValue)to write — that reads and ignores the arg like any signal. Usetitle.set(newValue)Expecting
initialto win when the key already exists — it is create-if-missing only; a persisted / peer value is authoritative andinitialis ignored (the local-first convention)Creating the synced signal BEFORE attaching the transport — the create-if-missing seed defers until first sync ONLY when a transport is already registered on the doc; created first, it seeds immediately (as if alone) and a fresh default can clobber a peer value on a clientId tie-break (#2380). Attach the transport (+ persistence) first
Storing an object/array and expecting per-field surgical updates — v1 is scalar (string/number/boolean); whole-value replace works but re-fires per replace. Use
syncedText/syncedListfor collaborative collectionsForgetting
.dispose()for a module-scope synced signal that outlives any reactive scope (inside a scope it auto-disposes via onCleanup)
See also: syncedStore · syncedText · syncedList
syncedStore function
<T extends Record<string, unknown>>(initial: T, options: SyncedStoreOptions) => SyncedStore<T>Build a flat store of synced fields from a plain initial object — the ergonomic layer over syncedSignal. Each field becomes its own SyncedSignal over one shared map, so store.title() reads reactively and store.title.set(v) writes through the CRDT. A single-key change still produces exactly one base-signal write: every field's observer runs, but only the field whose key changed calls base.set (the rest early-return on a cheap Set.has).
Example
const store = syncedStore({ title: "Untitled", done: false }, { doc })
store.title() // "Untitled"
store.title.set("Ship") // one CRDT write → one DOM update
store.done.set(true)
store.dispose() // tear down all fields (or rely on onCleanup in-scope)Common mistakes
Adding a key at runtime — the store's fields are fixed from the
initialobject's keys at construction; reshape by creating a new storeSharing one map across two unrelated stores —
{ map }names the map; one map = one store, or fields collideReading
storeas a plain object snapshot — each field is a SyncedSignal; call it (store.title()) to read reactively
See also: syncedSignal
SyncedSignal type
interface SyncedSignal<T> extends Signal<T> { dispose(): void }A Signal<T> bound to a CRDT entry. Identical to a normal Signal for reads/writes/tracking, plus dispose() to detach the CRDT observer (idempotent; auto-called via onCleanup when created inside a reactive scope).
Example
const s: SyncedSignal<number> = syncedSignal({ doc, key: "n", initial: 0 })See also: syncedSignal
SyncedStore type
type SyncedStore<T> = { readonly [K in keyof T]: SyncedSignal<T[K]> } & { dispose(): void }A mapped type — each key of the initial object becomes a SyncedSignal of that field's type, plus a store-level dispose() that tears down every field's observer.
Example
const store: SyncedStore<{ title: string }> = syncedStore({ title: "x" }, { doc })See also: syncedStore
CrdtAdapter type
interface CrdtAdapter { createDoc(): CrdtDoc } // + CrdtDoc.getMap → CrdtMap, CrdtMap.observe/transactThe engine-neutral seam. CrdtAdapter / CrdtDoc / CrdtMap abstract the CLIENT reactive bridge so syncedSignal/syncedStore never import a concrete engine. The bridge depends ONLY on this seam (+ @pyreon/reactivity); the Yjs implementation lives behind @pyreon/sync/yjs. Note: the seam ports the bridge, NOT the wire format — persistence/transport/relay are Yjs-coupled, so swapping engines re-platforms the infrastructure, not the bridge.
Example
function bindTitle(adapter: CrdtAdapter) {
const doc = adapter.createDoc()
return syncedSignal({ doc, key: "title", initial: "Untitled" })
}See also: FakeCrdtAdapter · createYjsDoc
LOCAL_ORIGIN constant
const LOCAL_ORIGIN: unique symbolTransaction-origin tag for a LOCAL write (a .set originating on this client). The bridge tags its CRDT writes with this; transports use the origin to prevent the NETWORK loop — they re-broadcast LOCAL-origin updates but NEVER a REMOTE-origin one. The bridge observer itself applies every change regardless of origin (the local echo is an Object.is no-op).
Example
doc.getMap("m").transact(() => map.set("k", v), LOCAL_ORIGIN)See also: REMOTE_ORIGIN
REMOTE_ORIGIN constant
const REMOTE_ORIGIN: unique symbolTransaction-origin tag for a REMOTE-applied update (received from a peer/relay). Transports apply inbound updates with this origin so they are NOT echoed back, which is what prevents the network loop. Gating the bridge OBSERVER on origin would be a bug — it must apply remote changes to drive the local UI; the loop guard belongs in the transport.
Example
doc.yDoc.transact(() => Y.applyUpdate(doc.yDoc, bytes), REMOTE_ORIGIN)See also: LOCAL_ORIGIN
FakeCrdtAdapter class
class FakeCrdtAdapter implements CrdtAdapter { createDoc(): CrdtDoc }An in-memory, dependency-free CrdtAdapter for unit-testing synced stores without standing up a real engine. Pair docs with connectFakeDocs(a, b) to simulate two peers in-process. It does NOT do state-vector reconciliation, so it can't model offline-reconnect convergence — use the Yjs adapter (createYjsDoc + a transport) for that.
Example
// FakeCrdtAdapter.createDoc() returns a CrdtDoc; construct FakeCrdtDoc
// directly to get the concrete type connectFakeDocs requires.
const a = new FakeCrdtDoc()
const b = new FakeCrdtDoc()
connectFakeDocs(a, b)
const sa = syncedSignal({ doc: a, key: "k", initial: 0 })
const sb = syncedSignal({ doc: b, key: "k", initial: 0 })
sa.set(5) // sb() becomes 5Common mistakes
Using the fake adapter to test offline-reconnect convergence — it has no state-vector merge; use the Yjs adapter for that scenario
Shipping the fake adapter to production — it is a test double with no persistence or real conflict resolution
See also: connectFakeDocs · createYjsDoc
connectFakeDocs function
(a: FakeCrdtDoc, b: FakeCrdtDoc) => { disconnect(): void }Link two in-memory FakeCrdtDocs so a write to one propagates to the other — the test analog of a transport. Returns a disconnect() to simulate going offline.
Example
const link = connectFakeDocs(a, b)
link.disconnect() // simulate offlineSee also: FakeCrdtAdapter
createYjsDoc function
(yDoc?: Y.Doc) => YjsCrdtDocCreate a CrdtDoc backed by a real Yjs Y.Doc (or wrap an existing one). Exported from @pyreon/sync/yjs — importing it pulls in yjs, which is why it is NOT on the core entry. .yDoc exposes the underlying Y.Doc for the transports / persistence helpers.
Example
import { createYjsDoc, connectViaWebSocket } from "@pyreon/sync/yjs"
const doc = createYjsDoc()
const title = syncedSignal({ doc, key: "title", initial: "Untitled" })
connectViaWebSocket(doc, "wss://sync.example.com/my-room?token=abc")See also: persistViaIndexedDB · connectViaWebSocket · syncedText
syncedText function
(doc: YjsCrdtDoc, key: string) => SyncedTextBind a Signal<string> to a Yjs Y.Text — a COLLABORATIVE string with character-level CRDT merge. Unlike syncedSignal (scalar last-writer-wins, which drops the loser's value), two peers editing different regions BOTH keep their edits. Use .insert(i, s) / .delete(i, n) (positional ops Y.Text merges faithfully) for true concurrent editing; .set(full) applies a minimal prefix/suffix diff (one replace) — handy for a controlled <textarea> but not a positional merge. Engine-specific (in @pyreon/sync/yjs, not behind the seam — collab text is coupled to the CRDT's text type).
Example
const body = syncedText(doc, "body")
// <textarea value={body()} onInput={e => body.set(e.currentTarget.value)} />
body.insert(0, "Hello ") // positional — merges with a concurrent peer edit
body.delete(0, 6)Common mistakes
Using
syncedSignalfor a collaboratively-edited string — scalar LWW drops one peer's edit; usesyncedTextso both are keptRelying on
.set(fullText)for concurrent multi-region editing — it is a single prefix/suffix-diff replace, not a positional merge; use.insert/.deletewhere concurrency matters
See also: syncedList · syncedSignal
syncedList function
<T>(doc: YjsCrdtDoc, key: string) => SyncedList<T>Bind a Signal<T[]> to a Yjs Y.Array — a COLLABORATIVE list with positional CRDT merge. Concurrent push/insert from two peers are BOTH kept (no item dropped). Render with a keyed <For each={() => list()} by={…}> so a remote change reconciles O(changed). .push / .insert(i, items) / .delete(i, count?) are positional; .set(next) does a coarse whole-list replace. Engine-specific (in @pyreon/sync/yjs).
Example
const items = syncedList<string>(doc, "todos")
items.push("buy milk", "walk dog") // merges with a concurrent peer push
items.insert(0, ["first"])
items.delete(1, 1)
// <For each={() => items()} by={(t) => t}>{(t) => <li>{t}</li>}</For>Common mistakes
Calling
.set(newArray)for concurrent edits — whole-list replace resolves by that coarse op, not a positional merge; use.push/.insert/.deleteRendering with
.map()instead of a keyed<For>— you lose the O(changed) reconcile a remote list change should give
See also: syncedText
syncedAwareness function
<T extends Record<string, unknown>>(doc: YjsCrdtDoc, initial?: T) => SyncedAwareness<T>Reactive EPHEMERAL presence — who's online + their live cursor — over the Yjs awareness protocol, a SEPARATE channel from the document CRDT (awareness is never merged into the doc and never persisted). Returns read signals (local / others / states) that recompute when any peer joins, leaves, or moves, plus setLocal / setLocalField to publish your own presence. Wired automatically to whatever transports are (or later get) connected to the doc — they share the doc's single Awareness. The relay is awareness-stateful, so a new client sees existing peers INSTANTLY and a crashed peer is purged on disconnect. Create it BEFORE connecting a transport (the transport peeks for the doc's awareness at connect time).
Example
import { createYjsDoc, syncedAwareness, connectViaWebSocket } from "@pyreon/sync/yjs"
const doc = createYjsDoc()
const presence = syncedAwareness<{ name: string; cursor?: { x: number; y: number } }>(
doc, { name: "Vít" },
)
connectViaWebSocket(doc, "wss://sync.example.com/room?token=abc")
// live cursors: window.addEventListener("mousemove", e =>
// presence.setLocalField("cursor", { x: e.clientX, y: e.clientY }))
// <For each={() => presence.others()} by={p => p.clientId}>
// {p => <Cursor color={p.state.color} at={p.state.cursor} />}</For>Common mistakes
Putting durable data in awareness — it is EPHEMERAL and never persisted; a peer state vanishes on disconnect. Use syncedSignal/syncedStore/syncedText for data that must survive
Creating it AFTER connecting a transport — the transport peeks for the doc awareness at connect, so presence created later is not wired. Create syncedAwareness BEFORE connectViaWebSocket / connectViaBroadcastChannel
Reading
others()/local()outside a reactive scope and expecting it to update — they are signals; read them inside JSX / an effect / a computed so the UI tracks presence changesTreating cursor coordinates as exact across clients — they are raw viewport points with no scroll / window-size normalization (good enough for v1; map to content coordinates if you need pixel parity)
Expecting
dispose()to announce your departure / tear down the shared awareness — it only detaches THIS view's observer. The TRANSPORT announces departure on disconnect, and the DOC owns teardown (doc.destroy()). So dispose the view freely (a second view + the transports keep working); call doc.destroy() for a full local teardownAssuming presence scales to hundreds of peers cheaply — every awareness change rebuilds the full peers snapshot (O(N) in peer count) and re-runs each
others()consumer; fine for the typical handful-to-dozens of collaborators, but a large cursor swarm will re-render on every mouse move (throttle cursor publishes; this is a v1 limit, not free)
See also: SyncedAwareness · PeerState · connectViaWebSocket · createSyncServer
SyncedAwareness type
interface SyncedAwareness<T> { setLocal(s: T): void; setLocalField<K extends keyof T>(k: K, v: T[K]): void; local: Signal<T | null>; others: Signal<PeerState<T>[]>; states: Signal<PeerState<T>[]>; awareness: Awareness; dispose(): void }The reactive presence handle from syncedAwareness. others is every peer EXCEPT you (the avatars / cursors to render); states includes you; local is your own published state. setLocal / setLocalField publish; awareness is the raw y-protocols escape hatch; dispose() detaches ONLY this view's observer (idempotent; auto-called via onCleanup in a reactive scope) — it does NOT destroy the doc-shared awareness (the doc owns that via doc.destroy()) and does NOT announce departure (the transport does, on disconnect).
Example
const p: SyncedAwareness<{ name: string }> = syncedAwareness(doc, { name: "Vít" })
p.others() // PeerState<{ name: string }>[] — other people hereSee also: syncedAwareness · PeerState
PeerState type
interface PeerState<T> { clientId: number; state: T; isLocal: boolean }One peer's presence entry: its awareness clientId (use it as the <For> key), its published state, and isLocal (whether it is you). others() returns only isLocal: false entries; states() returns all.
Example
<For each={() => presence.others()} by={p => p.clientId}>
{p => <Avatar name={p.state.name} />}
</For>See also: syncedAwareness · SyncedAwareness
connectViaBroadcastChannel function
(doc: YjsCrdtDoc, channelName: string) => { disconnect(): void }Same-origin CROSS-TAB sync over BroadcastChannel — edits in one tab appear in another tab of the same origin, no server. Includes a minimal state-vector handshake so a late-opening tab catches up. Follows the universal echo rule: a REMOTE-origin update is never re-broadcast, so there is no loop.
Example
const doc = createYjsDoc()
const link = connectViaBroadcastChannel(doc, "my-doc-room")
// edit in tab A → the same <h1> patches in place in tab B
link.disconnect()Common mistakes
Expecting cross-DEVICE sync — BroadcastChannel is same-origin/same-browser only; use connectViaWebSocket + a relay for cross-device
See also: connectViaWebSocket · persistViaIndexedDB
connectViaWebSocket function
(doc: YjsCrdtDoc, url: string, options?: WebSocketTransportOptions) => WebSocketTransportSync a YjsCrdtDoc to a relay over WebSocket — the CROSS-DEVICE transport. Sends our state vector on open (relay replies with the diff), then live updates; a REMOTE-origin update is never re-sent (no loop). Reconnects with exponential backoff by default. Uses the global WebSocket (browsers / Node 22+ / Bun / Deno); pass WebSocketImpl on older Node. Auth: put a token in the url query string — browser WebSockets can't set headers — which the relay's authorize hook reads. Exposes a REACTIVE synced signal + whenSynced() promise (the y-websocket convention) — synced becomes true once the initial sync round-trip completes; syncedSignal defers its create-if-missing seed on this internally so a fresh peer's default can't clobber a peer's real value (issue #2380). Attach the transport BEFORE creating synced signals.
Example
import { connectViaWebSocket, createYjsDoc } from "@pyreon/sync/yjs"
const doc = createYjsDoc()
const t = connectViaWebSocket(doc, "wss://sync.example.com/my-room?token=abc")
// t.synced() // reactive: false until the first sync round-trip completes
await t.whenSynced() // gate your OWN default writes on this
t.disconnect() // close + stop reconnectingCommon mistakes
Trying to set an Authorization header — browser WebSockets can't; pass the token in the URL query string and read it in the relay's
authorizeUsing it on old Node without a global WebSocket and not passing
WebSocketImpl— it throws; pass thewspackage's WebSocketTreating a 4401 close as retryable — that is the relay's authz rejection and is terminal; reconnect won't help
Writing an app-level DEFAULT for a key before
synced— it can race a peer's real value on a random-clientId tie-break and clobber it. Gate default writes onawait transport.whenSynced()/transport.synced(). (syncedSignal's OWN seed already defers internally; this is for your explicit writes.)Creating synced signals BEFORE attaching the transport — the seed-deferral guarantee needs the transport registered on the doc first, else the seed fires immediately (as if alone)
See also: createSyncServer · connectViaBroadcastChannel · syncedSignal
persistViaIndexedDB function
(doc: YjsCrdtDoc, dbName: string) => YjsPersistencePersist a YjsCrdtDoc to IndexedDB so edits survive a reload and the app works offline (thin wrapper over y-indexeddb). Browser-only — it opens the IndexedDB connection eagerly. AWAIT .whenSynced BEFORE creating syncedSignals so create-if-missing adopts the persisted value instead of racing the async load against a fresh seed.
Example
const doc = createYjsDoc()
const persist = persistViaIndexedDB(doc, "my-app-doc")
await persist.whenSynced // load persisted state FIRST
const title = syncedSignal({ doc, key: "title", initial: "Untitled" })Common mistakes
Creating syncedSignals before awaiting
.whenSynced— the fresh seed can race the async load and clobber the persisted valueCalling it under Node/SSR — it constructs an IndexedDB connection eagerly; importing is safe, calling is browser-only
See also: createYjsDoc · syncedSignal
createSyncServer function
(options: SyncServerOptions) => Promise<SyncServer>Start a Node/Bun WebSocket relay that brokers Yjs sync between clients sharing a room. Keeps one authoritative Y.Doc per room (so a late-joiner catches up), applies each inbound update, and broadcasts to the room's OTHER clients. Server-only (@pyreon/sync/server — imports ws + node:http, never enters a client bundle). The authorize(ctx) hook is the per-room/per-doc access gate: return false (or throw) to reject with close code 4401 before any data flows. Rooms are GC'd when the last client leaves — the relay is ephemeral (no persistence); clients keep their own copy. Pass server to attach to an existing http.Server instead of opening a port.
Example
import { createSyncServer } from "@pyreon/sync/server"
const relay = await createSyncServer({
port: 1234,
authorize: ({ room, token }) => token === secretFor(room), // REQUIRED in prod
})
// later: await relay.close()Common mistakes
Deploying without an
authorizehook — the default allows EVERY connection (dev-only); a real deployment MUST supply it or anyone with the room id can read/writeImporting
@pyreon/sync/serverinto client code — it pullsws+node:http; it is the server-only subpath by designExpecting the relay to persist data — it is ephemeral; durability lives on the clients (persistViaIndexedDB) or an external store
See also: connectViaWebSocket · AuthorizeContext
AuthorizeContext type
interface AuthorizeContext { room: string; token: string | null; req: IncomingMessage }Context passed to the relay's authorize hook: the room parsed from the URL path, the token query-string param (browser WebSockets can't set headers, so auth rides the query string), and the raw HTTP upgrade req (read cookies / headers here if you prefer).
Example
authorize: ({ room, token, req }) => verify(room, token)See also: createSyncServer
Package-level notes
Subpath layout:
@pyreon/sync(core bridge — engine-free, universal),@pyreon/sync/yjs(real Yjs engine + transports + persistence + collaborative text/lists — pulls inyjs),@pyreon/sync/server(Node/Bun relay — pulls inws+node:http, never import into client code).
CRDTs prevent lost UPDATES, not semantic conflicts: Never market this as 'never lose data'. Scalar
syncedSignalis last-writer-wins — the loser's value is silently dropped.syncedText/syncedListkeep both peers' ops but the merged result may be semantically nonsensical (interleaved sentences). Real apps still need conflict UX (presence, indicators, optional locking).
Bundle weight: A synced app ships
yjs(~40KB min+gz) +y-indexeddb+ the WebSocket client on TOP of the core runtime — realistically ~60KB+ gz. It is off the core hot path (opt-in/yjsimport) and justified by the capability, but a synced Pyreon app is NOT a 'smaller than Solid' app.
Awareness is ephemeral + the relay is stateful for it: Presence (syncedAwareness — who's here + cursors) rides a SEPARATE channel from doc updates, is NEVER persisted, and is purged the moment a peer disconnects. The relay tracks per-room awareness (a deliberate departure from its otherwise-stateless doc relay) so a new client sees existing peers instantly and a crashed client is cleaned up on socket close. Don't store anything durable in awareness, and create syncedAwareness BEFORE connecting a transport.
Authorization is table-stakes: The relay's
authorizedefault ALLOWS everything (dev only). Production MUST gate reads + writes per room/doc. Whole-doc CRDTs make partial (per-field) access genuinely hard — use per-doc rooms or permission-scoped sub-docs.
Native / PMTC sync is out of near-term scope: The
CrdtAdapterseam keeps a future Loro-via-FFI native engine door open, but compiler WS-emit + a native WS runtime + a CRDT-via-FFI engine are not in scope yet.