User Guide

xSync Documentation

xSync is an isomorphic TypeScript sync fabric for replicated actor logs. It provides signed, causal event replication across browser tabs, workers, Next.js API routes, Node.js servers, and edge runtimes — with S3 as the universal durable backbone.

Quick Start

The fastest way to get something running is a single client with an in-memory store and no transports. Add transports when you need cross-peer sync.

Install
npm install @decoperations/xsync-client
# or
pnpm add @decoperations/xsync-client
Minimal example
import { createXSync } from "@decoperations/xsync-client"

const sync = await createXSync({
  views: {
    count: {
      initial: 0,
      reduce: (n, event) =>
        event.type === "counter.increment" ? n + 1 : n,
    },
  },
})

sync.subscribe((state) => console.log("count:", state.count))

await sync.emit("counter.increment")
// → count: 1

await sync.emit("counter.increment")
// → count: 2
createXSync()auto-generates an Ed25519 identity, sets up an in-memory store, and joins the default actor (your identity's ID) on startup. All async — returns a ready client.

Installation

xSync is split into small, independently installable packages. Install only what you need.

@decoperations/xsync-clientcoreMain entry point — XSyncClient, createXSync()
@decoperations/xsync-corecoreTypes, event creation, signing, verification
@decoperations/xsync-store-memorystoreIn-process ephemeral store (default)
@decoperations/xsync-store-indexeddbstoreBrowser persistent store via IndexedDB
@decoperations/xsync-store-fsstoreNode.js filesystem store
@decoperations/xsync-store-s3wormstoreS3-backed immutable WORM store
@decoperations/xsync-transport-broadcast-channeltransportCross-tab sync via BroadcastChannel API
@decoperations/xsync-transport-wstransportWebSocket client transport
@decoperations/xsync-transport-httptransportHTTP polling transport (fallback)
@decoperations/xsync-transport-ssetransportServer-Sent Events transport
@decoperations/xsync-serverserverServer handler — Next.js App Router + Node.js HTTP/SSE/events ingestion
All packages are published to GitHub Packages under the @decoperations scope. Add the registry to your .npmrc: @decoperations:registry=https://npm.pkg.github.com

Core Concepts

Events

Everything in xSync is an event — an immutable, ordered record of what happened. Events are signed with Ed25519, content-hashed with SHA-256, and causally ordered with a Lamport clock. Any peer can verify any event without a server.

XSyncEvent structure
type XSyncEvent<T = unknown> = {
  id: string                    // SHA-256 content hash
  actorId: string               // Which actor (topic) this belongs to
  type: string                  // Domain type: "chat.message", "shape.moved", …
  payload: T                    // Arbitrary JSON payload

  writer: {
    id: string                  // Peer identity ID (public key fingerprint)
    publicKey: string           // Base64url Ed25519 public key
    seq: number                 // Monotonic per-writer sequence number
  }

  causality: {
    lamport: number             // Lamport logical timestamp
    parents: string[]           // Parent event IDs (causal graph edges)
    timestamp: string           // ISO 8601 wall clock (informational)
  }

  content: {
    hash: string                // SHA-256 of the unsigned body
    encoding: "json"
  }

  signature: {
    algorithm: "ed25519"
    value: string               // Base64url signature over the body bytes
  }
}

Actors

An actor is a named append-only log — a topic that multiple writers can contribute to. Actors are identified by any string: a UUID, a human-readable name, a room ID, a document ID, or a public key fingerprint.

When you call sync.emit("type", payload), the event is written to the default actor. You can emit to a specific actor by passing it as the third argument.

Writers

A writer is a single peer contributing to an actor. Each writer has its own monotonically increasing seq number per actor. Writers are identified by their public key fingerprint, making them verifiable without a central authority.

Causal DAG

Events across multiple writers form a multi-writer causal DAG(Directed Acyclic Graph). Each event's causality.parents array contains the event IDs of the current heads at emission time. This gives you causal ordering without coordination — concurrent events are detected, not arbitrated.

createXSync(config)

The main entry point. Returns a ready XSyncClient — identity generated, store initialized, actors joined, transports connected.

Full config reference
import { createXSync } from "@decoperations/xsync-client"

const sync = await createXSync({
  // Optional: bring your own identity (or a function that returns one)
  identity?: PeerIdentity | (() => Promise<PeerIdentity>)

  // Optional: persistent store (default: MemoryStore)
  store?: XSyncStore

  // Optional: transport adapters for peer sync
  transports?: XSyncTransport[]

  // Optional: named view definitions (reducers → typed state)
  views?: ViewDefinitions<V>

  // Optional: actor IDs to auto-join on start (default: [identity.id])
  actors?: string[]

  // Optional: default actorId when emitting (default: identity.id)
  defaultActor?: string
})

With IndexedDB persistence + BroadcastChannel sync

Browser app
import { createXSync } from "@decoperations/xsync-client"
import { indexedDbStore } from "@decoperations/xsync-store-indexeddb"
import { broadcastChannelTransport } from "@decoperations/xsync-transport-broadcast-channel"

const sync = await createXSync({
  store: indexedDbStore("my-app"),
  transports: [broadcastChannelTransport("my-app")],
  actors: ["global-room"],
  views: {
    messages: {
      initial: [] as Message[],
      reduce: (msgs, event) =>
        event.type === "chat.message"
          ? [...msgs, event.payload as Message]
          : msgs,
    },
  },
})

emit(type, payload?, actorId?)

Creates a signed event and appends it to the actor log, updates views, and broadcasts to all connected transports. Returns the created XSyncEvent.

// Emit to the default actor
const event = await sync.emit("todo.added", {
  id: crypto.randomUUID(),
  text: "Buy milk",
  done: false,
})

// Emit to a specific actor
await sync.emit("shape.moved", { x: 100, y: 200 }, "canvas-room-42")

// The returned event has the full structure including hash + signature
console.log(event.id)              // SHA-256 content hash
console.log(event.writer.seq)      // Monotonic sequence number
console.log(event.signature.value) // Base64url Ed25519 signature
Events are signed locally before being broadcast. No server round-trip required — any peer can verify the signature independently using the writer's public key embedded in the event.

subscribe() / watch()

Both methods return an unsubscribe function. Call it to stop receiving updates.

// Subscribe to all state changes
const unsub = sync.subscribe((state) => {
  console.log("full state:", state)
  console.log("messages:", state.messages)
})

// Watch a single named view — only fires when that view changes
const unsub2 = sync.watch("messages", (messages) => {
  setMessages(messages) // React setState, Zustand setter, etc.
})

// Unsubscribe
unsub()
unsub2()

// Read current state synchronously (no subscription needed)
const current = sync.state
const messages = sync.get("messages")

join(actorId) / leave(actorId)

join() loads existing events from the store, rebuilds views, sets up transport listeners, and triggers an initial sync round. leave() removes the actor from the active set — no further events are processed for it.

// Join a shared room
await sync.join("team-room-abc")

// Now events emitted to "team-room-abc" by any peer will be received

// Leave when done
await sync.leave("team-room-abc")
Actors listed in config.actors are automatically joined on startup. You only need to call join() for actors you want to subscribe to dynamically at runtime.

sync(actorId?)

Manually triggers a sync round: broadcasts a heads.request to all transports. Peers respond with their current heads, and any missing events are fetched. Called automatically on join().

// Sync all joined actors
await sync.sync()

// Sync a specific actor only
await sync.sync("team-room-abc")

asStore()

Returns a Redux-compatible store interface with getState() and subscribe(). Plug xSync state into any Redux-compatible consumer: Zustand, RTK, React-Redux, etc.

const store = sync.asStore()

// With Zustand
const useStore = create(() => store.getState())
store.subscribe(() => useStore.setState(store.getState()))

// With react-redux
const Provider = ({ children }) => (
  <ReactReduxProvider store={store}>{children}</ReactReduxProvider>
)

Defining Views

Views are named reducers over the event log. They produce typed, materialized state from events — like a persistent Array.reduce() that reacts to new events as they arrive.

View definition
type ViewDefinition<S, V extends ViewMap> = {
  // Starting state before any events
  initial: S

  // Pure reducer: (prevState, event) => nextState
  // Return the same reference if nothing changed (enables change detection)
  reduce: (state: S, event: XSyncEvent) => S

  // Optional: restrict which event types trigger this reducer
  input?: {
    eventTypes?: string[]   // Only run for these event types
    actorKinds?: string[]   // (future) filter by actor kind
  }
}

Typed State

Pass a ViewMap type parameter to createXSync for fully typed state and methods.

Typed views
type AppState = {
  messages: Message[]
  presence: Record<string, PresenceInfo>
  todos: Todo[]
}

const sync = await createXSync<AppState>({
  views: {
    messages: {
      initial: [] as Message[],
      reduce: (msgs, e) =>
        e.type === "chat.message" ? [...msgs, e.payload as Message] : msgs,
    },
    presence: {
      initial: {} as Record<string, PresenceInfo>,
      reduce: (p, e) =>
        e.type === "presence.update"
          ? { ...p, [e.writer.id]: e.payload as PresenceInfo }
          : p,
    },
    todos: {
      initial: [] as Todo[],
      reduce: (todos, e) => {
        if (e.type === "todo.added")   return [...todos, e.payload as Todo]
        if (e.type === "todo.removed") return todos.filter(t => t.id !== (e.payload as { id: string }).id)
        return todos
      },
    },
  },
})

// Fully typed:
sync.state.messages   // Message[]
sync.state.presence   // Record<string, PresenceInfo>
sync.get("todos")     // Todo[]

sync.watch("messages", (msgs: Message[]) => { ... }) // typed callback

Filtering Events by Type

Use input.eventTypes to prevent a reducer from running on irrelevant events — a performance optimization for high-throughput actors.

views: {
  messages: {
    initial: [] as Message[],
    input: { eventTypes: ["chat.message"] },  // skip all other types
    reduce: (msgs, e) => [...msgs, e.payload as Message],
  },
}

MemoryStore

In-memory store. No persistence. Fast. Used as the default when no store is provided. Good for tests, ephemeral sessions, or server-side per-request state.

import { memoryStore } from "@decoperations/xsync-store-memory"

const sync = await createXSync({ store: memoryStore() })

IndexedDB Store

Browser-persistent store using the idb library. Events survive page reloads. Indexed by actor and writer for efficient queries.

import { indexedDbStore } from "@decoperations/xsync-store-indexeddb"

const sync = await createXSync({
  store: indexedDbStore("my-app-v1"),  // dbName optional, default: "xsync"
})
Combine with broadcastChannelTransport for a fully offline-first browser app: events persist to IndexedDB and sync across tabs instantly.

Filesystem Store

Node.js store that writes events as JSON files. Layout mirrors the S3 store — useful for local development, CLI tools, or edge deployments with a writable filesystem.

import { fsStore } from "@decoperations/xsync-store-fs"

const sync = await createXSync({
  store: fsStore("./data/xsync"),
  // Events written to: ./data/xsync/logs/{actorId}/{writerId}/{seq}-{id}.json
  // Heads:             ./data/xsync/heads/{actorId}/{writerId}.json
  // Snapshots:         ./data/xsync/snapshots/{actorId}/{viewId}.json
})

S3 WORM Store

Durable store backed by any S3-compatible object store (AWS S3, Cloudflare R2, MinIO). UsesIfNoneMatch: "*" on writes — events are immutable once written. Reads are eventually consistent; the prefix layout is designed for efficient actor-level list queries.

import { s3wormStore } from "@decoperations/xsync-store-s3worm"

const sync = await createXSync({
  store: s3wormStore({
    bucket: "my-xsync-bucket",
    prefix: "prod",             // optional key prefix, default: "xsync"
    client: {                   // passed directly to S3Client constructor
      region: "us-east-1",
      credentials: { accessKeyId: "…", secretAccessKey: "…" },
    },
  }),
})
The S3 store uses @aws-sdk/client-s3 which is a server-only dependency. Do not import it in browser bundles — use IndexedDB in the browser and S3WORM on the server.

BroadcastChannel Transport

Synchronizes events across browser tabs (or Node.js workers) sharing the same origin via the BroadcastChannel API. No server required. Ideal for multi-tab apps and the live demo.

import { broadcastChannelTransport } from "@decoperations/xsync-transport-broadcast-channel"

const sync = await createXSync({
  transports: [broadcastChannelTransport("my-app")],
  // channelName defaults to "xsync"
})

Each client both sends (via connect()) and receives (via listen()). Sender's own messages are filtered out — only remote peers receive them.

WebSocket Transport

Client-side WebSocket transport. Connects to a WebSocket server, sends messages as JSON, and receives inbound messages through the async iterator interface.

import { webSocketTransport } from "@decoperations/xsync-transport-ws"

const sync = await createXSync({
  transports: [
    webSocketTransport("wss://sync.myapp.com/ws"),

    // Or with a dynamic URL per peer:
    webSocketTransport((peer) => `wss://sync.myapp.com/ws/${peer.id}`),
  ],
})
WebSocketTransport.listen() throws — it is a client-only transport. Server-side listener support requires a server package (planned).

HTTP Polling Transport

Fallback transport that polls a server for new heads at a configurable interval and sends events via POST /events. Works through any HTTP/1.1 proxy.

import { httpTransport } from "@decoperations/xsync-transport-http"

const sync = await createXSync({
  transports: [
    httpTransport("https://sync.myapp.com/api", {
      pollIntervalMs: 3000,             // default: 5000ms
      headers: { Authorization: "Bearer …" },
    }),
  ],
})

// Server endpoints expected:
// GET  /api/heads?actors=room-1,room-2  → ActorHead[]
// POST /api/events                       → accepts XSyncMessage

SSE Transport

Server-Sent Events transport. Server pushes events via EventSource; client sends via POST. Works through HTTP/1.1 proxies and CDNs that don't support WebSocket upgrades.

import { sseTransport } from "@decoperations/xsync-transport-sse"

const sync = await createXSync({
  transports: [
    sseTransport("https://sync.myapp.com/stream", {
      postUrl: "https://sync.myapp.com/events", // default: url with /stream replaced
      headers: { Authorization: "Bearer …" },
    }),
  ],
})

Server Handler

@decoperations/xsync-server provides a Web Request/Response API handler that runs on any runtime supporting the Fetch API — Next.js App Router, Bun, Cloudflare Workers, Deno, or plain Node.js 18+.

It handles the server side of all three server-backed transports: HTTP polling, SSE stream, and event ingestion. All events are verified (Ed25519 + content hash) before being stored and broadcast.

Next.js App Router — app/api/xsync/[[...path]]/route.ts
import { createXSyncServer } from "@decoperations/xsync-server"
import { s3wormStore } from "@decoperations/xsync-store-s3worm"

const server = createXSyncServer({
  store: s3wormStore({ bucket: "my-bucket" }),
  cors: "https://myapp.com",            // optional CORS header
  onEvent: (event) => {                 // optional hook
    console.log("new event:", event.type, event.actorId)
  },
})

export const { GET, POST, OPTIONS } = server.nextHandlers()

Routes

GET /heads?actors=id1,id2Returns ActorHead[] for the given actors (all if omitted)
GET /events?actorId=&writerId=&fromSeq=Returns XSyncEvent[] for a writer, optionally from a sequence number
GET /stream?actors=id1,id2SSE stream — sends events as they arrive, filtered by actor
POST /eventsXSyncMessage bodyAccepts events.push, heads.request, events.request — returns appropriate XSyncMessage or 204
SSE requires a long-running process. For serverless (Vercel Edge Functions, Lambda), use the HTTP polling transport instead — clients poll GET /heads and fetch events via GET /events.
Node.js HTTP server
import { createServer } from "node:http"
import { createXSyncServer } from "@decoperations/xsync-server"
import { memoryStore } from "@decoperations/xsync-store-memory"

const xsync = createXSyncServer({ store: memoryStore(), cors: "*" })

createServer(async (req, res) => {
  const url = `http://localhost${req.url}`
  const webReq = new Request(url, { method: req.method, headers: req.headers as HeadersInit })
  const webRes = await xsync.handle(webReq)

  res.writeHead(webRes.status, Object.fromEntries(webRes.headers))
  const body = await webRes.arrayBuffer()
  res.end(Buffer.from(body))
}).listen(3001)

Sync Protocol

xSync uses a 4-message heads-exchange protocol. No server coordination required — any two peers can sync directly.

Message flow
Peer A                              Peer B
  │                                     │
  │── heads.request { actorIds } ──────►│  "what are your heads?"
  │                                     │
  │◄── heads.response { heads } ────────│  "here are my seq numbers"
  │                                     │
  │── events.request { actorId,         │  "I'm missing seq 5-7"
  │     writerId, fromSeq } ───────────►│
  │                                     │
  │◄── events.response { events } ──────│  "here they are"
  │
  └── (also: events.push on emit)       one-way real-time push

Message Types

heads.request{ actorIds: string[] }Ask a peer for their current heads (highest seq per writer)
heads.response{ heads: ActorHead[] }Reply with current heads
events.request{ actorId, writerId, fromSeq }Request specific events from a writer, starting at seq
events.response{ events: XSyncEvent[] }Reply with the requested events
events.push{ events: XSyncEvent[] }Push new events to all peers immediately on emit()

Network Topology

xSync is best understood as a replicated actor-log protocol, not as a mandatory full-mesh P2P network. Any two reachable peers can exchange xSync messages directly, but many real deployments include browsers, mobile apps, workers, relays, and durable stores with very different networking constraints.

There are two separate questions: protocol-level peer compatibility and network-level reachability. xSync gives you the first. Your transports and deployment topology determine the second.

Multi-transport on the same actor

Yes. A single xSync client can attach multiple transports to the same actor set by passing more than one transport in createXSync(). The client sends the same sync messages over each configured transport, and inbound events converge into the same local actor log and store.

const sync = await createXSync({
  store: indexedDbStore("room-123"),
  transports: [
    broadcastChannelTransport("room-123"),   // same-device tabs/workers
    sseTransport("https://sync.example.com/stream"), // remote live fanout
    httpTransport("https://sync.example.com"),       // universal fallback
  ],
  actors: ["room-123"],
})

That means one actor can span multiple connectivity modes at once: tabs on the same device, background workers, browser-to-server sync, and server-to-storage recovery can all exchange the same event history.

What happens in practice

Typical hybrid deployment
Browser tab A ─ BroadcastChannel ─ Browser tab B
      │
      ├──────────── SSE / HTTP ────────────┐
      │                                    │
      ▼                                    ▼
 Local IndexedDB                    xSync server / relay
                                         │
                                         └──── durable store (S3, fs, SQLite)

The important detail is that xSync does not create an automatic routing fabric between transports. A bridge exists only where a node is attached to both sides. For example, a browser with BroadcastChannel plus SSE can bridge same-device tab activity into a server-backed stream, and an xSync server can ingest events over HTTP and fan them back out over SSE.

What xSync is and is not

Protocol-level peer syncsupportedAny two reachable peers can exchange heads.request, heads.response, events.request, events.response, and events.push.
Same actor over many transportssupportedYes. Configure multiple transports on one client or connect multiple node types to the same actor.
Automatic full-mesh P2Pnot built-inNo. Browsers, phones, edge runtimes, and many serverless environments are not mutually reachable by default.
Automatic discovery / NAT traversal / DHTnot built-inNo. Add those through custom transports or external network adapters if your product needs them.

Capability Classes

xSync networks are usually heterogeneous. A device joining the network does not have to provide every service. A better model is: every node joins actors, then offers whatever capabilities it can safely provide.

Four major classes

Mobile / tabletclient-firstMobile browser apps, installed apps, and PWAs. Good at local writes, verification, and cache. Poor default fit for always-on relay or public inbound reachability.
Laptop / desktopmixedBrowser apps, desktop apps, extensions, and local daemons. Can range from simple client replica to opportunistic LAN peer or local background service.
Server / special-purpose nodeservice-firstCloud servers, home servers, NAS devices, worker processes, edge handlers, routers, kiosks, or other dedicated nodes. Best fit for relay, history serving, policy, and projections.
Existing distributed networkexternal substrateIPFS, libp2p, BitTorrent, Nostr, Matrix, S3-compatible object stores, blockchains, DNS, DID systems, or privacy-routing networks. These usually join through adapters, not as native xSync peers.

Capability map

Write and sign eventsall classesCore replica capability. Mobile, desktop, and server nodes can all originate valid xSync events.
Verify inbound eventsall classesCore trust model. Every serious node should verify signatures and content hashes before accepting events.
Local cache / offline replaymobile, desktop, serverIndexedDB, filesystem, SQLite, or memory let nodes replay actor history locally.
Serve heads / history to othersdesktop daemon, serverUseful for catch-up peers, LAN helpers, relays, and durable replicas.
Relay / fanout / transport bridgeserver, some desktop nodesBest handled by stable, reachable nodes. Not a safe default assumption for browsers or mobile devices.
External discovery, blob storage, settlement, anchoringexternal networksUse adapters when you need DHT discovery, content-addressed blobs, checkpoint anchoring, naming, or incentive layers.

Recommended framing

Describe xSync as a heterogeneous capability-based sync fabric. All participants share the same signed event protocol. Some participants additionally provide network services such as relay, durable history, transport bridging, projections, or policy enforcement.

Existing distributed networks should be treated as capability providers, not as automatic sources of truth. xSync events remain the semantic record. External networks provide transport, storage, discovery, or settlement, but applications should still verify hashes, signatures, actor membership, and policy locally.

Multi-Writer Causal DAG

Multiple peers can write to the same actor concurrently. Each writer maintains its own append-only sequence, and the actor log merges them causally using Lamport timestamps and parent pointers.

// Peer A and Peer B both write to "shared-room"
// They exchange heads, fetch missing events, and merge

// Events always arrive in causal order — parents before children
// Concurrent events (same parents) are merged deterministically by lamport

The ActorLog.getAhead(theirHeads) method computes exactly which events a peer is missing — no full log transfer needed.

Identities

Each xSync client has a PeerIdentity — an Ed25519 keypair. Auto-generated if not provided. You can persist and restore it to maintain a stable identity across sessions.

import { generateIdentity } from "@decoperations/xsync-core"

// Generate once and persist
const identity = await generateIdentity()
localStorage.setItem("xsync-identity", JSON.stringify(identity))

// Restore on next load
const stored = localStorage.getItem("xsync-identity")
const identity = stored ? JSON.parse(stored) : await generateIdentity()

const sync = await createXSync({ identity })
The private key is part of the PeerIdentity object — treat it as a secret. Do not log it, send it over the network, or store it insecurely.

Alternatively, pass an async factory function — useful when identity comes from an external auth system:

const sync = await createXSync({
  identity: async () => {
    const session = await auth.getSession()
    return session.xsyncIdentity
  },
})

Use Cases

xSync shines wherever you need signed, causal, multi-writer event sync — in the browser, on the server, or across both. Below are real-world patterns, what makes each a good fit, and a few situations where a different tool is the better call.

Great fits

💬 Live in-room chat

Multiple users join the same actor (room ID). Messages are signed events, so every participant can verify who sent what without a central authority. History persists to IndexedDB, so late joiners and page reloads replay instantly from local cache before the network round-trip completes.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-broadcast-channel (tabs) or xsync-transport-ws (cross-device)

const sync = await createXSync({
  store: indexedDbStore("chat-" + roomId),
  transports: [broadcastChannelTransport("room-" + roomId)],
  actors: [roomId],
  views: {
    messages: {
      initial: [] as ChatMessage[],
      input: { eventTypes: ["chat.message"] },
      reduce: (msgs, e) => [...msgs, e.payload as ChatMessage],
    },
  },
})

🖼 Collaborative whiteboard / canvas

Shapes, paths, and cursor positions are emitted as discrete events. Each mutation is causally ordered, so concurrent shape moves from two users merge deterministically. The causal DAG makes undo/redo trivial — walk the parents array backward.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-ws

await sync.emit("shape.moved", { id: shapeId, x: 240, y: 180 }, canvasRoomId)
await sync.emit("cursor.update", { x: 240, y: 180 }, canvasRoomId)

// Reducer materialises current shape positions
reduce: (shapes, e) =>
  e.type === "shape.moved"
    ? { ...shapes, [e.payload.id]: e.payload }
    : shapes,

🎮 Multiplayer game state

Turn-based or real-time games benefit from signed moves — each action is cryptographically attributed to a player, giving you a verifiable game history you can replay or audit. The causal DAG naturally prevents replays of old moves (seq is monotonic per writer).

Packages: xsync-client + xsync-store-memory + xsync-transport-ws

await sync.emit("game.move", { from: "e2", to: "e4" }, matchId)

views: {
  board: {
    initial: initialBoardState,
    input: { eventTypes: ["game.move"] },
    reduce: (board, e) => applyMove(board, e.payload),
  },
}

✅ Shared todo / task list

Offline edits accumulate locally in IndexedDB. On reconnect, the heads-exchange protocol merges changes from other devices without conflict — each add/remove is its own event, so concurrent edits on different items compose cleanly.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-sse

await sync.emit("todo.added",   { id: crypto.randomUUID(), text, done: false })
await sync.emit("todo.toggled", { id, done: true })
await sync.emit("todo.removed", { id })

reduce: (todos, e) => {
  if (e.type === "todo.added")   return [...todos, e.payload as Todo]
  if (e.type === "todo.toggled") return todos.map(t => t.id === e.payload.id ? { ...t, done: e.payload.done } : t)
  if (e.type === "todo.removed") return todos.filter(t => t.id !== e.payload.id)
  return todos
},

📝 Live document co-editing metadata

xSync is an excellent layer for the metadata around collaborative documents — presence indicators, cursor positions, comments, and annotations — without replacing a CRDT for the text content itself. Pair it with Yjs or Automerge for character-level edits.

Packages: xsync-client + xsync-store-memory + xsync-transport-ws

await sync.emit("cursor.update", { writerId: identity.id, line: 42, col: 7 }, docId)
await sync.emit("comment.added", { id, line: 42, body: "Nice!" }, docId)

views: {
  cursors: {
    initial: {} as Record<string, CursorPos>,
    reduce: (c, e) => e.type === "cursor.update"
      ? { ...c, [e.writer.id]: e.payload as CursorPos } : c,
  },
}

🔐 Audit log with tamper evidence

Every xSync event is Ed25519-signed and SHA-256 content-hashed. Storing events in an S3 WORM store gives you an append-only, cryptographically verifiable audit trail — any modification or deletion is detectable by re-verifying the event chain.

Packages: xsync-client + xsync-store-s3worm

// Server-side: emit policy change as a signed event
await sync.emit("policy.updated", {
  policyId,
  changedBy: session.userId,
  diff: patchPayload,
}, "audit-log")

// Verify any event independently — no server needed
import { verifyEvent } from "@decoperations/xsync-core"
const valid = await verifyEvent(event) // checks hash + Ed25519 signature

⚙️ Distributed work queue

Workers emit task.claimedevents with their writer ID — because each writer's seq is monotonic, only the first claim wins when two workers race. The task log replicas converge to the same state everywhere, giving you durable claim semantics without a dedicated queue service.

Packages: xsync-client + xsync-store-s3worm + xsync-transport-http

await sync.emit("task.claimed",   { taskId, workerId: identity.id }, "work-queue")
await sync.emit("task.completed", { taskId, result },                  "work-queue")

reduce: (tasks, e) => {
  if (e.type === "task.claimed" && !tasks[e.payload.taskId]?.claimedBy)
    return { ...tasks, [e.payload.taskId]: { ...tasks[e.payload.taskId], claimedBy: e.payload.workerId } }
  return tasks
},

🧩 Browser extension sync

Extension background scripts and content scripts share the same browser origin, making BroadcastChannel a zero-server sync layer for extension state. Bookmarks, settings, and read history sync instantly across all open tabs of your extension's popup and sidebar.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-broadcast-channel

// Runs in background service worker and popup — same channel, instant sync
const sync = await createXSync({
  store: indexedDbStore("ext-v1"),
  transports: [broadcastChannelTransport("extension")],
  actors: ["settings", "history"],
})

🗂 Multi-tab app state

Shopping carts, auth state, notification badges — any state that should stay consistent across open tabs can be powered by BroadcastChannel transport with no server at all. The user gets a single coherent view regardless of how many tabs are open.

Packages: xsync-client + xsync-store-memory + xsync-transport-broadcast-channel

const sync = await createXSync({
  transports: [broadcastChannelTransport("storefront")],
  views: {
    cart: {
      initial: [] as CartItem[],
      reduce: (items, e) =>
        e.type === "cart.added" ? [...items, e.payload as CartItem]
        : e.type === "cart.removed" ? items.filter(i => i.id !== e.payload.id)
        : items,
    },
  },
})

🔗 Peer-to-peer file metadata sync

Sync file names, sizes, and content hashes across peers without a server — let the causal log track which files have been added or deleted. Actual binary transfer happens out-of-band (WebRTC DataChannel, IPFS, etc.); xSync handles the metadata graph.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-ws

await sync.emit("file.announced", {
  name: "report.pdf",
  size: 1_048_576,
  sha256: "a3f5…",
  peerId: identity.id,
}, shareRoomId)

👥 Real-time presence / online indicators

Heartbeat events carrying a writer's ID and timestamp build a live presence map. Because events are signed, you can trust that a presence ping actually came from the claimed peer. A simple TTL in the reducer drops stale entries after 30 s of silence.

Packages: xsync-client + xsync-store-memory + xsync-transport-ws

setInterval(() => sync.emit("presence.ping", { at: Date.now() }), 10_000)

reduce: (online, e) => {
  if (e.type !== "presence.ping") return online
  const staleThreshold = Date.now() - 30_000
  return { ...online, [e.writer.id]: e.payload.at }
  // filter stale entries in a selector, not the reducer
},

🗳 Live voting / polls

Votes are signed events — cryptographically tied to a voter's identity, so you can enforce one-vote-per-writer in the reducer without a server. The tally is a pure derivation of the log, replayable and auditable at any point in time.

Packages: xsync-client + xsync-store-s3worm + xsync-transport-sse

await sync.emit("poll.voted", { optionId: "B" }, pollId)

reduce: (tally, e) => {
  if (e.type !== "poll.voted") return tally
  // One vote per writer — first write wins
  if (tally.voters.has(e.writer.id)) return tally
  return {
    counts: { ...tally.counts, [e.payload.optionId]: (tally.counts[e.payload.optionId] ?? 0) + 1 },
    voters: new Set([...tally.voters, e.writer.id]),
  }
},

📡 IoT sensor data stream

Sensors emit signed events to an actor representing a device or location. An aggregator node joins the actor, materialises views (rolling averages, alert thresholds), and stores the raw log in S3 WORM for durable, tamper-evident telemetry history.

Packages: xsync-client + xsync-store-s3worm + xsync-transport-http

// On the sensor (Node.js, edge runtime)
await sync.emit("sensor.reading", { temp: 22.4, humidity: 61 }, deviceId)

// On the aggregator
views: {
  latest: {
    initial: null as SensorReading | null,
    input: { eventTypes: ["sensor.reading"] },
    reduce: (_prev, e) => e.payload as SensorReading,
  },
}

🪝 Webhook event replay log

Incoming webhooks (Stripe, GitHub, Twilio) are written as signed events the moment they arrive. Because the store is immutable and append-only, replaying all events for a failed processor is just re-running the reducer from position 0 — no message broker required.

Packages: xsync-client + xsync-store-s3worm

// In your Next.js API route (server-side only)
export async function POST(req: Request) {
  const body = await req.json()
  await sync.emit("webhook.received", { source: "stripe", body }, "webhooks")
  return Response.json({ ok: true })
}

🌐 Edge-first app with S3 backbone

Next.js edge functions emit events, and S3 WORM is the only durable store — no traditional database required. Because S3 is globally available and xSync events are self-describing and verifiable, any edge region can write independently and the log converges.

Packages: xsync-client + xsync-store-s3worm + xsync-transport-sse

// Edge function — runs in every region
const sync = await createXSync({
  store: s3wormStore({ bucket: "app-events", prefix: "prod" }),
  actors: ["orders"],
})
await sync.emit("order.placed", { orderId, items }, "orders")

📔 Local-first notes app

IndexedDB provides offline storage, BroadcastChannel keeps every browser tab in sync without a server, and SSE delivers remote sync when connectivity is available. The tri-transport setup is a drop-in local-first architecture — no extra infrastructure.

Packages: xsync-client + xsync-store-indexeddb + xsync-transport-broadcast-channel + xsync-transport-sse

const sync = await createXSync({
  store: indexedDbStore("notes-v1"),
  transports: [
    broadcastChannelTransport("notes"),   // instant cross-tab
    sseTransport("/api/sync/stream"),     // remote when online
  ],
  actors: [userId],
})

🚩 Distributed config / feature flags

Flag changes are emitted as signed events from a control plane and consumed by all connected services. Because transports are pluggable, you can fan out over WebSocket, SSE, or HTTP polling — no third-party feature-flag service needed.

Packages: xsync-client + xsync-store-s3worm + xsync-transport-sse

await sync.emit("flag.updated", { key: "new-checkout", enabled: true }, "flags")

views: {
  flags: {
    initial: {} as Record<string, boolean>,
    input: { eventTypes: ["flag.updated"] },
    reduce: (flags, e) => ({ ...flags, [e.payload.key]: e.payload.enabled }),
  },
}

🏢 Team presence in a SaaS app

Know who is viewing which page of your app in real time — without polling. Each page mount emits a presence.viewed event; unmount emits presence.left. The view materialises a live map of userId → page for the whole team.

Packages: xsync-client + xsync-store-memory + xsync-transport-ws

useEffect(() => {
  sync.emit("presence.viewed", { page: router.pathname }, "team-" + orgId)
  return () => { sync.emit("presence.left", { page: router.pathname }, "team-" + orgId) }
}, [router.pathname])

reduce: (map, e) => {
  if (e.type === "presence.viewed") return { ...map, [e.writer.id]: e.payload.page }
  if (e.type === "presence.left")   return Object.fromEntries(Object.entries(map).filter(([k]) => k !== e.writer.id))
  return map
},

Not a good fit

Relational queries and joins. xSync is an ordered event log, not a relational database. If your app needs JOIN, GROUP BY, or ad-hoc SQL queries over large datasets, reach for Postgres, Supabase, or PlanetScale instead.
Large binary files — use xsync-artifacts. xSync events carry JSON, not bytes. The correct pattern: upload your blob directly to S3/R2, then emit a signed artifact.uploaded event carrying the hash, size, and URL. See the Artifacts section below.
Rich text collaborative editing. Character-level CRDT merging (insert at position 4, delete char 7) requires data structures like Yjs or Automerge that are optimized for operational transforms. xSync handles event-level sync beautifully alongside these libraries, but should not replace them for the text content itself.
High-frequency write workloads (>1 000 events/sec per actor). The causal DAG grows with every event — at very high write rates the heads list and parent arrays expand quickly. Batch or aggregate events upstream before feeding them into xSync, or shard into multiple actors.
Anonymous public forums with open write access.xSync's actor model is built around identified writers with Ed25519 keypairs. Fully anonymous public writes are possible but require careful rate-limiting and moderation on top, since there is no built-in concept of "untrusted writer." A traditional server-side moderation queue (e.g. Discourse, Flarum) is a better starting point for anonymous content.

Artifacts — Binary File Storage

xSync events are JSON — they are not a blob store. The correct pattern for files is: upload the bytes directly to storage, then emit a signed xSync event carrying the ArtifactRef (hash, size, URL). Every peer sees the reference event, verifies the SHA-256 hash, and fetches the blob independently from storage. No bytes ever touch the event log.

Install
pnpm add @decoperations/xsync-artifacts
Upload to S3 / R2 and emit a signed reference event
import { createXSync } from "@decoperations/xsync-client"
import { uploadArtifact, s3BlobStore } from "@decoperations/xsync-artifacts"

const sync = await createXSync({ actors: ["shared-room"] })

const store = s3BlobStore({
  bucket: "my-assets",
  // Cloudflare R2: endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`
  // MinIO:         endpoint: "http://localhost:9000", forcePathStyle: true
  client: { region: "us-east-1" },
})

// From a browser file input
const file = fileInput.files[0]
const { artifact, event } = await uploadArtifact(sync, file, { store })

// artifact.hash   — SHA-256 of the bytes (content-addressable)
// artifact.path   — canonical S3/R2 URL
// artifact.size   — byte count
// event           — signed xSync event, replicated to all peers

// Peers subscribe and get the ref — they fetch the file themselves:
sync.subscribe((state) => {
  const latest = state.uploads[state.uploads.length - 1]
  fetch(latest.path).then(r => r.blob()).then(render)
})
Keys are SHA-256 hashes by default — uploads are automatically deduplicated. If the same file is uploaded twice, the second call skips the PUT and just emits the reference event.

HTTP blob store

For a custom upload endpoint or any HTTP file server — no AWS SDK required:

import { httpBlobStore } from "@decoperations/xsync-artifacts"

const store = httpBlobStore({
  baseUrl: "https://uploads.myapp.com/files",
  headers: { Authorization: `Bearer ${token}` },
})

const { artifact } = await uploadArtifact(sync, file, { store })

Custom storage

Implement the BlobStore interface to target any backend — Cloudflare KV, IPFS, Supabase Storage, etc.:

import type { BlobStore } from "@decoperations/xsync-artifacts"

const myStore: BlobStore = {
  async put(key, data, opts) {
    // upload bytes, return canonical URL
    return `https://cdn.example.com/${key}`
  },
  async get(key) {
    // optional — return bytes or null
    const res = await fetch(`https://cdn.example.com/${key}`)
    return res.ok ? new Uint8Array(await res.arrayBuffer()) : null
  },
}

SQLite Store + SQL Projections

@decoperations/xsync-store-sqlite is an XSyncStore backed by better-sqlite3. It stores events in a SQLite database and lets you define SQL projections that run atomically in the same transaction as each event append — so your SQL read model is always consistent with the event log.

Install
pnpm add @decoperations/xsync-store-sqlite better-sqlite3
Define projections and query with raw SQL
import { createXSync } from "@decoperations/xsync-client"
import { sqliteStore, defineProjection } from "@decoperations/xsync-store-sqlite"

const messages = defineProjection({
  schema: `
    CREATE TABLE IF NOT EXISTS messages (
      id         TEXT PRIMARY KEY,
      text       TEXT NOT NULL,
      author_id  TEXT NOT NULL,
      ts         TEXT NOT NULL
    )
  `,
  reduce(db, event) {
    if (event.type !== "chat.message") return
    db.prepare(
      "INSERT OR IGNORE INTO messages (id, text, author_id, ts) VALUES (?, ?, ?, ?)"
    ).run(event.payload.id, event.payload.text, event.writer.id, event.causality.timestamp)
  },
})

const store = sqliteStore({
  file: "./xsync.db",          // or ":memory:" for tests
  projections: [messages],
})

const sync = await createXSync({ store, actors: ["my-room"] })

// Query directly — full SQL, joins, aggregation, full-text search, anything
const recent = store.db
  .prepare("SELECT * FROM messages ORDER BY ts DESC LIMIT 20")
  .all()

// Join xSync internals with your projection tables
const withHeads = store.db.prepare(`
  SELECT m.*, h.seq as writer_seq
  FROM messages m
  JOIN xsync_heads h ON h.actor_id = m.author_id
  ORDER BY m.ts DESC
`).all()

Internal schema

xSync manages three tables automatically. Your projection tables live alongside them in the same database file.

xsync_events   (id, actor_id, type, payload JSON, writer_id, writer_seq,
                lamport, parents JSON, ts, hash, sig)
xsync_heads    (actor_id, writer_id, seq, event_id, hash, updated_at)
xsync_snapshots (actor_id, view_id, state JSON, last_event_id, created_at)

-- Your tables:
messages       (id, text, author_id, ts)
todos          (id, text, done, created_at)
-- ...

Drizzle integration

import { drizzle } from "drizzle-orm/better-sqlite3"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"

const messagesTable = sqliteTable("messages", {
  id:       text("id").primaryKey(),
  text:     text("text").notNull(),
  authorId: text("author_id").notNull(),
  ts:       text("ts").notNull(),
})

const db = drizzle(store.db)

// Fully typed queries
const rows = await db
  .select()
  .from(messagesTable)
  .orderBy(desc(messagesTable.ts))
  .limit(20)
Call store.rebuildProjections() after adding a new projection to an existing database — it replays all stored events through the new reducer in a single transaction.

Something missing? Open an issue or send a PR.