Self-hosted real-time CRDT sync server.
Open-source alternative to Liveblocks and PartyKit — no vendor lock-in, no merge conflicts.
Built on Effect TS — type-safe errors, composable async, runtime schema validation.
Meridian is a real-time CRDT sync server that runs self-hosted on native infrastructure or at the edge on Cloudflare Workers — same SDK, same protocol, two deployment targets.
You pick a CRDT type (counter, set, register, presence), apply operations from any client, and every client converges to the same value automatically — no locks, no last-write-wins bugs.
# Start the server
MERIDIAN_SIGNING_KEY=$(openssl rand -hex 32) docker compose up -d
# Issue a token
curl -X POST http://localhost:3000/v1/namespaces/my-room/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"client_id": 1, "ttl_ms": 3600000}'import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
import { MeridianProvider, useAwareness, useGCounter } from "meridian-react";
import { Schema } from "effect";
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });
function Room() {
const { value, increment } = useGCounter("gc:views");
// Awareness: ephemeral cursor positions, not persisted
const { peers, update } = useAwareness("cursors", CursorSchema);
return <p>{value} views · {peers.length} peers live</p>;
}
function App() {
return (
<MeridianProvider client={client}>
<Room />
</MeridianProvider>
);
}The SDK exposes a simple imperative API on top of an Effect-based core — use runPromise to bridge into your existing async code, or compose with Effect.gen for full type-safe pipelines.
import { MeridianClient } from "meridian-sdk";
import { Effect, Schema } from "effect";
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
// Counters, registers, sets — all conflict-free
const views = client.gcounter("gc:views");
views.increment(1);
views.onChange(v => console.log("views:", v));
// Every handle has stream() — composable with Effect
import { Stream } from "effect";
await Effect.runPromise(
views.stream().pipe(Stream.take(5), Stream.runForEach(v => Effect.log(`views: ${v}`)))
);
// Awareness — ephemeral cursors, not persisted, schema-validated at runtime
const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });
const cursors = client.awareness("cursors", CursorSchema);
cursors.update({ x: 120, y: 80 });
cursors.onChange(peers => console.log("live cursors:", peers));
client.close();[dependencies]
meridian-client = "0.1.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }use meridian_client::MeridianClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = MeridianClient::connect("ws://localhost:3000", "my-room", &token).await?;
let views = client.gcounter("gc:views");
views.increment(1).await?;
println!("views: {}", views.value());
views.on_change(|v| println!("views updated: {v}"));
client.close().await;
Ok(())
}| Type | Use case | Example key |
|---|---|---|
GCounter |
Page views, likes | gc:views |
PNCounter |
Inventory, votes | pn:stock |
ORSet |
Shopping cart, tags | or:cart |
LwwRegister |
User profile, config | lw:title |
Presence |
Who's online, visitor count | pr:room |
CRDTMap |
Structured document with typed fields | cm:doc |
RGA |
Ordered sequence — collaborative text editing | rg:doc |
TreeCRDT |
Hierarchical tree — outlines, document trees, mind maps | tr:outline |
Non-CRDT ephemeral channel for high-frequency transient state (cursors, selections, "is typing"). Updates fan out in real time but are never persisted — use Presence when you need durability and TTL-based cleanup, Awareness when you need raw speed.
CRDTMap lets you assign a different CRDT type to each key within a single document. Each key merges independently using its own conflict resolution semantics.
RGA (Replicated Growable Array) is Meridian's ordered-sequence CRDT — the same algorithm behind collaborative editors like Google Docs. Every character has a stable, unique identity across all peers, so concurrent insertions and deletions converge without conflict.
const doc = client.rga("rg:document");
// Insert text at position 5 (0-indexed character offset)
doc.insert(5, "Hello");
// Delete 3 characters starting at position 2
doc.delete(2, 3);
// Read the current text
const { text } = doc.value();
// React to remote changes
doc.onChange(({ text }) => editor.setValue(text));// React
const { text, insert, delete: del } = useRga("rg:document");Concurrent edits from multiple clients are merged automatically — the final text is identical on every peer regardless of arrival order.
TreeCRDT implements the Kleppmann et al. 2021 move-operation algorithm — the only known CRDT that handles concurrent move operations correctly. This makes it suitable for outlines, task hierarchies, document trees, and mind maps where nodes are frequently reorganized.
const tree = client.tree("tr:outline");
// Create nodes
const root = tree.addNode(null, { title: "Project" });
const task1 = tree.addNode(root, { title: "Research" });
const task2 = tree.addNode(root, { title: "Implementation" });
// Move a node to a different parent — safe under concurrent moves
tree.move(task2, task1);
// Read the current tree
const { roots } = tree.value();
// roots = [{ id, data, children: [...] }]
// React to remote changes
tree.onChange(({ roots }) => renderTree(roots));// React
const { roots, addNode, move } = useTree("tr:outline");Concurrent moves (e.g. two peers moving the same node to different parents at the same time) are resolved deterministically — no cycles, no lost nodes.
Aggregate data across multiple CRDTs in a single request — no need to read them one by one.
// Sum all page view counters matching a glob pattern
const result = await client.query({ from: "gc:views-*", aggregate: "sum" });
console.log(result.value); // total across all matched GCounters
// Union all shopping carts
const carts = await client.query({ from: "or:cart-*", aggregate: "union" });
// Latest config value across regions
const config = await client.query({ from: "lw:config-*", aggregate: "latest" });Or reactively in React:
const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useQuery(spec);See the Query Engine docs for the full aggregation table and where clause filters.
Subscribe once — get a push every time matching CRDTs change. No polling, no manual re-fetch.
const handle = client.liveQuery({ from: "gc:views-*", aggregate: "sum" });
handle.onResult(result => console.log("live total:", result.value));
// Cancel
handle.close();Or in React — useLiveQuery connects on mount, updates on every delta, and disconnects on unmount:
const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useLiveQuery(spec);The SDK re-sends subscriptions automatically after a WebSocket reconnect. Set type to avoid re-executing queries for unrelated CRDT deltas:
// Only re-executes when a GCounter changes — skips ORSet/LwwRegister deltas
client.liveQuery({ from: "gc:views-*", type: "gcounter", aggregate: "sum" });Deploy Meridian to the edge in minutes — no server, no Docker, no ops.
cd crates/meridian-edge
cp .dev.vars.example .dev.vars # add your MERIDIAN_SIGNING_KEY
wrangler dev # local dev
wrangler deploy # production on CloudflareThe edge runtime uses Durable Objects for per-namespace state (replaces sled), compiles to WASM via wasm-bindgen, and exposes the exact same WebSocket + REST API as the native server. Your SDK client connects to either without any code change:
import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
// Native server
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
// Edge worker (same SDK, different URL)
const client = await Effect.runPromise(
MeridianClient.create({ url: "https://my-worker.workers.dev", namespace: "my-room", token })
);See crates/meridian-edge/ for full setup.
npm
| Package | Description |
|---|---|
meridian-sdk |
TypeScript SDK — Effect-based, msgpack, fully typed. Includes stream() on all CRDT handles and MeridianLive Layer for DI |
meridian-react |
React hooks — useGCounter, usePresence, useAwareness, etc. |
meridian-devtools |
Devtools panel — CRDT inspector, event stream, WAL history, op latency P50/P99 |
meridian-cli |
CLI — meridian inspect and meridian replay for terminal-based debugging |
Rust crates
| Crate | Description |
|---|---|
server |
Native binary — axum + tokio |
meridian-core |
Shared logic — CRDTs, auth, protocol (no runtime dep) |
meridian-edge |
Cloudflare Workers runtime — WASM, Durable Objects |
meridian-storage |
Pluggable storage backends — sled, PostgreSQL, Redis, in-memory; S3 WAL archive (--features wal-archive-s3) |
meridian-cluster |
Multinode clustering — Redis Pub/Sub + HTTP push transport |
meridian-client |
Rust client SDK — all 8 CRDT handles, WebSocket transport, FakeTransport for tests |
meridian-pg |
Postgres extension — CRDT SQL functions + notify trigger for pg-sync |
Meridian can sync directly from your existing Postgres tables — no separate message broker needed. Every SQL write on a CRDT column is broadcast to all WebSocket clients in real time.
SQL UPDATE → meridian_pg trigger → pg_notify → Meridian server → WebSocket → client
↑
WAL stream (for payloads > 8 KB)
How it works:
- Install the
meridian_pgPostgres extension — adds SQL functions (gcounter_increment,pncounter_increment,orset_add,lww_set, …) and a notify trigger - Add a
BYTEAcolumn per CRDT type to your table and attach the trigger - Start Meridian with
--features pg-syncandDATABASE_URL
MERIDIAN_FEATURES=pg-sync \
DATABASE_URL=postgres://user:pass@localhost/mydb \
MERIDIAN_WAL_CONNSTR=postgres://user:pass@localhost/mydb \
docker compose --profile pg upThe server opens a logical replication slot (meridian_wal) and subscribes to pg_notify — both paths feed the same idempotent merge, so large payloads (RGA, Tree) are covered by WAL while small ones arrive via notify.
See examples/postgres-articles/ for a full working demo.
| Variable | Default | Description |
|---|---|---|
MERIDIAN_BIND |
0.0.0.0:3000 |
TCP bind address |
MERIDIAN_DATA_DIR |
./data |
sled storage path |
MERIDIAN_SIGNING_KEY |
(random) | 32-byte hex ed25519 seed |
MERIDIAN_WEBHOOK_URL |
(unset) | Webhook endpoint URL |
MERIDIAN_WEBHOOK_SECRET |
(unset) | HMAC-SHA256 signing secret |
REDIS_URL |
(unset) | Redis URL — enables cluster mode (--features cluster) |
MERIDIAN_PEERS |
(unset) | Comma-separated peer URLs — enables HTTP cluster mode (--features cluster-http) |
MERIDIAN_NODE_ID |
(auto) | Unique node ID — auto-derived from hostname+port if unset |
MERIDIAN_ANTI_ENTROPY_SECS |
30 |
Gossip interval in seconds |
S3_BUCKET |
(unset) | Enable S3 WAL archive — bucket name (--features wal-archive-s3) |
S3_ENDPOINT |
(unset) | S3-compatible endpoint override (R2, MinIO, LocalStack) |
S3_REGION |
us-east-1 |
AWS region |
S3_KEY_PREFIX |
wal/ |
Object key prefix for WAL segments |
WAL_SEGMENT_SIZE |
500 |
Number of WAL entries per S3 segment |
DATABASE_URL |
(unset) | PostgreSQL URL — enables pg-sync transport and Postgres storage (--features pg-sync) |
MERIDIAN_WAL_CONNSTR |
(unset) | Replication connection URL — enables WAL stream for payloads > 8 KB |
MERIDIAN_WAL_SLOT |
meridian_wal |
Logical replication slot name |
MERIDIAN_WAL_PUB |
meridian_pub |
Publication name (FOR ALL TABLES) |
