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.
npm install @decoperations/xsync-client
# or
pnpm add @decoperations/xsync-clientimport { 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: 2createXSync()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-client | core | Main entry point — XSyncClient, createXSync() |
| @decoperations/xsync-core | core | Types, event creation, signing, verification |
| @decoperations/xsync-store-memory | store | In-process ephemeral store (default) |
| @decoperations/xsync-store-indexeddb | store | Browser persistent store via IndexedDB |
| @decoperations/xsync-store-fs | store | Node.js filesystem store |
| @decoperations/xsync-store-s3worm | store | S3-backed immutable WORM store |
| @decoperations/xsync-transport-broadcast-channel | transport | Cross-tab sync via BroadcastChannel API |
| @decoperations/xsync-transport-ws | transport | WebSocket client transport |
| @decoperations/xsync-transport-http | transport | HTTP polling transport (fallback) |
| @decoperations/xsync-transport-sse | transport | Server-Sent Events transport |
| @decoperations/xsync-server | server | Server handler — Next.js App Router + Node.js HTTP/SSE/events ingestion |
@decoperations scope. Add the registry to your .npmrc: @decoperations:registry=https://npm.pkg.github.comCore 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.
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.
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
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 signaturesubscribe() / 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")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.
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.
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 callbackFiltering 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"
})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: "…" },
},
}),
})@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 XSyncMessageSSE 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.
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,id2 | Returns 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,id2 | SSE stream — sends events as they arrive, filtered by actor |
| POST /events | XSyncMessage body | Accepts events.push, heads.request, events.request — returns appropriate XSyncMessage or 204 |
GET /heads and fetch events via GET /events.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.
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 pushMessage 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.
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
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 sync | supported | Any two reachable peers can exchange heads.request, heads.response, events.request, events.response, and events.push. |
| Same actor over many transports | supported | Yes. Configure multiple transports on one client or connect multiple node types to the same actor. |
| Automatic full-mesh P2P | not built-in | No. Browsers, phones, edge runtimes, and many serverless environments are not mutually reachable by default. |
| Automatic discovery / NAT traversal / DHT | not built-in | No. 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 / tablet | client-first | Mobile 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 / desktop | mixed | Browser apps, desktop apps, extensions, and local daemons. Can range from simple client replica to opportunistic LAN peer or local background service. |
| Server / special-purpose node | service-first | Cloud 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 network | external substrate | IPFS, 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 events | all classes | Core replica capability. Mobile, desktop, and server nodes can all originate valid xSync events. |
| Verify inbound events | all classes | Core trust model. Every serious node should verify signatures and content hashes before accepting events. |
| Local cache / offline replay | mobile, desktop, server | IndexedDB, filesystem, SQLite, or memory let nodes replay actor history locally. |
| Serve heads / history to others | desktop daemon, server | Useful for catch-up peers, LAN helpers, relays, and durable replicas. |
| Relay / fanout / transport bridge | server, some desktop nodes | Best handled by stable, reachable nodes. Not a safe default assumption for browsers or mobile devices. |
| External discovery, blob storage, settlement, anchoring | external networks | Use 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.
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 lamportThe 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 })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
JOIN, GROUP BY, or ad-hoc SQL queries over large datasets, reach for Postgres, Supabase, or PlanetScale instead.artifact.uploaded event carrying the hash, size, and URL. See the Artifacts section below.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.
pnpm add @decoperations/xsync-artifactsimport { 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)
})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.
pnpm add @decoperations/xsync-store-sqlite better-sqlite3import { 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)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.