A real-time sync engine for TypeScript. Keeps a server-side database in sync with any number of browser clients — offline-first, with optimistic local writes, automatic conflict resolution, and end-to-end type inference.
You bring your own types and your own database. reflectdb handles the protocol, the op log, conflicts, reconnection, and subscriptions.
┌──────────────┐ writes ┌──────────────┐ writes ┌──────────────┐
│ Browser A │ ──────────▶ │ Server │ ◀────────── │ Browser B │
│ (optimistic) │ deltas │ (authoritive)│ deltas │ (optimistic) │
│ │ ◀────────── │ │ ──────────▶ │ │
└──────────────┘ └──────────────┘ └──────────────┘
▲ │
│ offline ▼
└────── IndexedDB ───── op log (in-memory / SQLite / Postgres)
- Demos
- Why reflectdb
- Features
- Use Cases
- Installation
- Quick Start
- Recipes
- WebSocket sync with SQLite + Drizzle
- Typed params for multi-tenant queries
- Authentication and room-based access control
- Per-column merge for collaborative editing
- Custom conflict resolvers
- Validating client payloads
- Ephemeral messages (cursors, presence, typing)
- Typed presence
- Per-user query results
- Read-only views
- Server-driven game loops
- Transactional writes with
server.tx - Windowed sync and pagination
- Auto-generated REST API
- High availability with Postgres
- Whiteboard + Pictionary example
- Infinite Tetris example
- Architecture
- Core Concepts
- API Reference
- Configuration Reference
- Development
- License
| Demo | Try it | What it demonstrates |
|---|---|---|
| Infinite multiplayer Tetris | Play live · source | Optimistic input prediction, server reconciliation and gravity, a live leaderboard, per-player progression, and Bun SQLite persistence in one perpetual game. Open two tabs to add another player. |
| Collaborative whiteboard | Draw live · source | Freeform drawing by default, optional Pictionary rounds, guest-authenticated rooms, ephemeral cursors, chat, presence, and per-user query results. Rooms and everything in them are deleted 30 minutes after they are created. Open two tabs to draw with yourself. |
Both demos run on one auto-stopping Fly Machine with no volume, so the first load after an idle period may take a moment. Their data is intentionally ephemeral across deployments and Machine replacement.
Most real-time sync libraries force you to choose: CRDTs (powerful but opaque), or simple pub/sub (fast but brittle). reflectdb sits in the middle — per-row operations with hybrid logical clocks for causal ordering, validated through a server-side pipeline so your database stays authoritative.
You define your schema once, and the same types flow to both sides:
const { rows, insert } = useSync("todos");
// ^? Todo[] ^? (id, { title, done, createdAt? }) => voidNo code generation. No glue layer. No second source of truth.
Bring your own stack. reflectdb is agnostic about:
- Your database — any TypeScript ORM, raw SQL driver, Map, or REST API works. The
query/mutatecallbacks hand youdbuntouched. - Your row types — plain TypeScript types, Drizzle
$inferSelect, Kysely, Prisma, anything. Declare them witht<MyRow>(). - Your HTTP server — Bun, Node, Deno, Cloudflare Workers, anything fetch-compatible. Transports expose handler functions you wire to routes.
Optional bits (use what you want):
- Drizzle ORM — if you point
tableat a Drizzle table, row types are auto-inferred. - Server op log storage — SQLite (for single-node) or Postgres (for HA). Omit it and the op log is in-memory.
- React / Svelte bindings — use the core client directly if you prefer.
- Real-time sync over WebSocket, Server-Sent Events, or HTTP long-polling
- Offline-first — optimistic local writes, queued and replayed on reconnect
- End-to-end type safety — schema defines row types, query params, writable fields, and which columns the server owns
- Per-row and per-column conflict resolution —
lww,merge,server, or a custom resolver - Causal ordering via hybrid logical clocks (HLC) — no dependence on synchronized wall clocks
- Pluggable storage — in-memory, SQLite, or Postgres for the server op log; memory or IndexedDB for the browser
- Auto-generated REST —
server.rest()turns your schema into CRUD endpoints that broadcast deltas - Room-based access control — scope clients to
org/:orgIdor arbitrary patterns - Rate limiting — global and per-table, fail-open
- Op log compaction — configurable retention for old accepted ops
- High availability — shared Postgres + optional cross-instance polling
- Framework bindings — React hooks, Svelte stores, and a vanilla-JS helper; the core client works anywhere
- Ephemeral channels — presence, cursors, typing indicators that never touch the op log, with a room snapshot on join and a pluggable adapter (Redis included) so presence spans a fleet
- Typed presence —
presence()in the schema,usePresence()in the component, key derived for you - Read-only views —
view()entries that recompute on their dependencies and reject writes at both levels - Windowed sync — paginate large tables with
loadMore+useTotalCount - Server-side toolkit —
tx(transaction + auto-notify),lock/tryLock, and self-disposinginterval/timeout
- Collaborative editing (docs, whiteboards, spreadsheets)
- Multi-device note apps, todo apps, inbox-like UIs
- Live dashboards where multiple clients view and edit the same state
- Local-first apps that need to work offline and merge on reconnect
- Admin tools that should "just update" when someone else changes a row
- Field-service or retail apps on spotty networks
- Games or canvases with presence indicators and live cursors
bun add reflectdb
# or
npm install reflectdbShips both ESM and CommonJS, so import and require both work:
import { defineSyncQueries, t } from "reflectdb"; // or "reflectdb/core"const { defineSyncQueries, t } = require("reflectdb");Everything else is a subpath — reflectdb/server, reflectdb/client, reflectdb/react, and so on. The bare reflectdb specifier is an alias for reflectdb/core, the surface both sides share.
Peer dependencies are all optional:
bun add react # for reflectdb/react
bun add drizzle-orm # if you want auto-inferred row types from Drizzle tables
# Svelte + vanilla have no peer depsA complete sync server in ~30 lines. No ORM, no database — just plain types and an in-memory Map.
// schema.ts
import { defineSyncQueries, t } from "reflectdb/core";
export type Todo = {
id: string;
title: string;
done: boolean;
createdAt: Date;
};
export const queries = defineSyncQueries({
todos: {
row: t<Todo>(),
conflict: "lww",
serverSet: ["createdAt"], // server always sets this, clients cannot
},
});// server.ts
import { serve } from "bun";
import { createSyncServer } from "reflectdb/server";
import { createWsServerTransport } from "reflectdb/transport/ws";
import { queries, type Todo } from "./schema";
const todos = new Map<string, Todo>();
const transport = createWsServerTransport();
const server = createSyncServer({ queries, transport, serverId: "s1" });
server.auth(async (req) => {
// validate req.headers.get("authorization")
return { userId: "user-1" };
});
server.implement("todos", {
query: () => [...todos.values()],
mutate: async (op) => {
if (op.type === "delete") todos.delete(op.rowId);
else todos.set(op.rowId, { id: op.rowId, ...(op.payload as Partial<Todo>) } as Todo);
},
serverSet: { createdAt: () => new Date() },
});
// Wire WebSocket handlers to your HTTP server
serve({
port: 3001,
fetch(req, srv) {
const url = new URL(req.url);
if (url.pathname === "/sync") {
const clientId = crypto.randomUUID();
if (srv.upgrade(req, { data: { clientId } })) return;
}
return new Response("ok");
},
websocket: {
open(ws) { transport.handleOpen(ws.data.clientId, ws); },
message(ws, data) { transport.handleMessage(ws.data.clientId, String(data)); },
close(ws) { transport.handleClose(ws.data.clientId); },
pong(ws) { transport.handlePong(ws.data.clientId); },
},
});// app.tsx
import { SyncProvider, useSync, useSyncStatus } from "reflectdb/react";
import { createIndexedDBStorage } from "reflectdb/client/storage/indexeddb";
export function App() {
return (
<SyncProvider
url="ws://localhost:3001/sync"
token="..."
tables={["todos"]}
storage={createIndexedDBStorage({ dbName: "myapp" })}
>
<TodoList />
</SyncProvider>
);
}
function TodoList() {
const { rows, insert, update, remove } = useSync("todos");
const status = useSyncStatus();
return (
<div>
<p>Status: {status}</p>
{rows.map((t) => (
<label key={t.id}>
<input type="checkbox" checked={t.done} onChange={() => update(t.id, { done: !t.done })} />
{t.title}
<button onClick={() => remove(t.id)}>x</button>
</label>
))}
<button onClick={() => insert(crypto.randomUUID(), { title: "New", done: false })}>
Add
</button>
</div>
);
}Open two tabs — edits in one appear in the other within a round-trip. Close the laptop, edit offline, reopen — pending ops replay automatically.
The repo ships two end-to-end examples: examples/whiteboard/, a collaborative drawing app with two modes (freeform and Pictionary), and examples/tetris/, one perpetual Tetris game with no player cap. Between them they exercise the patterns below in one place. The snippets here are minimal, copy-paste-friendly references; see the examples for how they fit together.
If you use Drizzle, point table at it and row types flow automatically. Swap the Map for bun:sqlite + Drizzle and add a persistent op log:
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { eq } from "drizzle-orm";
import { defineSyncQueries } from "reflectdb/core";
import { createSyncServer, createSqliteStorage } from "reflectdb/server";
const todos = sqliteTable("todos", {
id: text("id").primaryKey(),
title: text("title").notNull(),
done: integer("done", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
const queries = defineSyncQueries({
todos: { table: todos, conflict: "lww", serverSet: ["createdAt"] },
});
const db = drizzle(new Database("app.db"));
const storage = createSqliteStorage({ path: "sync.db" });
const server = createSyncServer({ queries, db, transport, storage, serverId: "s1" });
server.implement("todos", {
query: (_ctx, db) => db.select().from(todos),
mutate: async (op, _ctx, db) => {
if (op.type === "delete") {
await db.delete(todos).where(eq(todos.id, op.rowId));
} else {
await db.insert(todos)
.values({ id: op.rowId, ...op.payload })
.onConflictDoUpdate({ target: todos.id, set: op.payload });
}
},
serverSet: { createdAt: () => new Date() },
});Declare query params with t<T>() so the client must pass them and the server can use them to scope queries:
import { defineSyncQueries, t } from "reflectdb/core";
type Post = { id: string; title: string; orgId: string };
const queries = defineSyncQueries({
posts: {
row: t<Post>(),
params: t<{ orgId: string }>(),
tables: ["posts"], // change-detection hint for delta computation
pk: "id",
conflict: "lww",
readonly: ["orgId"], // clients cannot write this
},
});
// server
server.implement("posts", {
query: (ctx, kyselyDb) =>
kyselyDb.selectFrom("posts").where("orgId", "=", ctx.params.orgId).selectAll().execute(),
mutate: async (op, ctx, kyselyDb) => { /* ... */ },
});
// client
client.sync("posts", { orgId: "org-42" });Works with any ORM or raw driver.
auth() runs on every connection. Return an AuthContext — anything with a userId. It's passed to every query, mutate, and authorize call.
server.auth(async (req) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
const session = await validateToken(token);
if (!session) throw new Error("unauthorized");
return { userId: session.userId, orgId: session.orgId };
});For multi-tenant apps, use room() to pin a client to a subset of data:
server.room("org/:orgId", async ({ params, auth }) => {
if (!auth.memberships.includes(params.orgId)) {
return { ok: false, reason: "not a member of this org" };
}
// return nothing (or `{ ok: true }`) to allow the subscription
});Room keys are resolved from the subscription's params and fail closed: params that
address a pattern only partially, or that produce a key the pattern can't match, are
rejected rather than falling back to an unscoped, cross-room subscription. Set
room in implement() to require a specific pattern for a query.
The whiteboard example wires this up with better-auth — see examples/whiteboard/auth.ts.
When two users edit different fields of the same row, lww would throw one write away. merge keeps both:
const queries = defineSyncQueries({
docs: { row: t<Doc>(), conflict: "merge" }, // per-column HLCs
});User A writes { title: "Hello" } at HLC 100
User B writes { body: "world" } at HLC 200
→ Result: { title: "Hello", body: "world" } (both accepted)
For domain logic — counters, highest-bid-wins, append-only lists — supply a resolver:
const queries = defineSyncQueries({
auctions: {
row: t<Auction>(),
conflict: {
policy: "custom",
resolve: (incoming, existing) => {
const bid = (incoming.payload.bid as number) ?? 0;
if (bid <= (existing.row?.bid as number ?? 0)) {
throw new Error("bid too low"); // rejects the op
}
return { row: { ...existing.row, ...incoming.payload } };
},
},
},
});t<MyRow>() is a compile-time phantom — it erases at runtime. reflectdb validates
protocol structure (an op's payload must be a non-array object or null) but never
its contents, so a client can send { title: 12345 } or extra keys and they reach
your mutate untouched. readonly and serverSet strip named fields; they don't
type-check what's left.
Validate in mutate, with whatever library you already use — reflectdb has no opinion
and no dependency here:
import { z } from "zod";
import { MutationError } from "reflectdb/core";
const Todo = z.object({
id: z.string(),
title: z.string().min(1).max(200),
done: z.boolean(),
}).strict(); // reject unknown keys instead of passing them through
server.implement("todos", {
query: (ctx, db) => db.select().from(todos),
mutate: async (op, ctx, db) => {
if (op.type === "delete") {
await db.delete(todos).where(eq(todos.id, op.rowId));
return;
}
// Updates carry a partial delta, not a whole row.
const schema = op.type === "insert" ? Todo : Todo.partial();
const parsed = schema.safeParse(op.payload);
if (!parsed.success) {
throw new MutationError("outside_shape", parsed.error.message);
}
await db.insert(todos).values({ id: op.rowId, ...parsed.data })
.onConflictDoUpdate({ target: todos.id, set: parsed.data });
},
});Two details that matter:
- Throw
MutationError, not a plainError. OnlyMutationErrorcarries anErrorReasonthrough to the client'sonError; anything else is reported asserver_error. - Write the parsed value, not
op.payload. Writing the raw payload after validating it defeats.strict()and any coercion the schema applied — and it also feeds unvalidated data into reflectdb's mirror, which is what conflict resolution compares against.
The same applies to authorize, and to writes arriving through server.rest() —
both run the identical pipeline.
Ephemeral events are room-scoped broadcasts that bypass the op log — ideal for high-frequency signals:
import { useEphemeral } from "reflectdb/react";
const { events, broadcast } = useEphemeral({
key: "cursor",
userId: currentUserId,
ttlMs: 10_000,
});
// on mouse move
broadcast({ x: e.clientX, y: e.clientY });
// render peers
Object.values(events).map((c) => <Cursor x={c.x} y={c.y} />);Fan-out follows the sender's query subscriptions: recipients are the clients
subscribed to the same queries, narrowed to the sender's room when one is resolved. A
client that has called no sync() yet has no audience, so its ephemeral messages reach
nobody. The userId on the wire is always the authenticated one — the client-supplied
value is ignored — and a client-supplied ttlMs is clamped server-side.
Subscribing to a room also delivers a snapshot of that room's live ephemeral
state, so a client that joins mid-session sees the peers already there instead of
waiting for each one to move again. Snapshots arrive as ordinary ephemeral events
and exclude the joiner's own entries.
By default this state lives in the server process, which is correct on one node
and invisible across a fleet — two clients on different instances never see each
other. Point ephemeral.adapter at shared infrastructure to fix both halves; see
Ephemeral (presence).
The whiteboard renders peer cursors this way — see examples/whiteboard/app.tsx.
presence() is useEphemeral with the shape declared in the schema instead of at the
call site. The channel key is derived from the entry name plus its serialized params, so
two components watching the same presence entry always agree on the key.
// schema.ts
import { defineSyncQueries, presence, t } from "reflectdb/core";
export const queries = defineSyncQueries({
cursor: presence({
state: t<{ x: number; y: number; name: string }>(),
params: t<{ gameId: string }>(), // part of the derived key
ttlMs: 10_000,
}),
});// app.tsx — usePresence comes from the typed factory, not the bare import
import { createSyncReact } from "reflectdb/react";
import { queries } from "./schema";
export const { SyncProvider, useSync, usePresence } = createSyncReact(queries);
function Cursors({ gameId }: { gameId: string }) {
const { peers, set } = usePresence("cursor", { gameId });
// ^? { userId: string; state: { x, y, name } }[]
useEffect(() => {
const onMove = (e: PointerEvent) =>
set({ x: e.clientX, y: e.clientY, name: myName });
window.addEventListener("pointermove", onMove);
return () => window.removeEventListener("pointermove", onMove);
}, [set]);
return peers.map((p) => <Cursor key={p.userId} {...p.state} />);
}Details worth knowing:
- No server registration. Presence entries are not queries — there is no
server.implement/server.viewfor them. They ride the same ephemeral channel and are room-scoped by the sender's active subscriptions. peersexcludes you. Ephemeral events are only delivered to other clients, so render your own cursor from local state.- Peers are keyed by connection, not by account. Presence entries are keyed by
clientIdend to end — on the wire, in the server's store, and inpeers— so two tabs from one login are two peers with two cursors. Put the display identity instate(asnameabove) if you need it; the authenticateduserIdrides along on every event for authorization and display. - Params are required when declared, exactly like
useSync—usePresence("cursor")fails to compile if the entry declares params. - React only.
createSyncSvelte/createSyncVanillahave no presence helper; usesync.sendEphemeral/sync.onEphemeral(or the store'sephemeral()) with your own key there.derivePresenceKey(name, params)is exported fromreflectdb/reactif you want to interoperate with the same channel by hand.
A query callback is just a function — it can return different rows depending on the caller's auth. reflectdb re-runs it whenever the listed tables change, so each subscriber gets a personalized view that stays live.
The whiteboard uses this to keep the round's secret word out of the wire for everyone except the active drawer:
const queries = defineSyncQueries({
roundWord: {
row: t<{ id: string; gameId: string; word: string }>(),
params: t<{ gameId: string }>(),
tables: ["games", "game_secrets"], // re-run on these
},
});
server.implement("roundWord", {
query: async (ctx, db) => {
const game = await db.select().from(games).where(eq(games.id, ctx.params.gameId)).get();
if (!game || game.state !== "drawing") return [];
if (game.currentDrawerId !== ctx.auth.userId) return []; // guessers see []
const secret = await db.select().from(gameSecrets)
.where(eq(gameSecrets.gameId, ctx.params.gameId)).get();
return secret?.word ? [{ id: ctx.params.gameId, gameId: ctx.params.gameId, word: secret.word }] : [];
},
mutate: async () => { throw new Error("read-only"); },
tables: ["games", "game_secrets"],
});The game_secrets table isn't registered in defineSyncQueries, so it's never broadcast directly. Calling server.notifyChange("game_secrets") from the engine fans out the recomputed roundWord result to whichever client is now the drawer.
The recipe above is a query that happens to reject writes. view() makes that the
declaration: the entry has no mutate, useSync(...) returns only { rows, loading },
and a write that reaches the server anyway is rejected with readonly_query.
// schema.ts
import { defineSyncQueries, view, t } from "reflectdb/core";
export const queries = defineSyncQueries({
leaderboard: view({
row: t<{ id: string; name: string; points: number }>(),
params: t<{ gameId: string }>(),
deps: ["games", "scores"], // re-run when either table changes
}),
});// server.ts — server.view, not server.implement
server.view("leaderboard", (ctx, db) =>
db.select().from(scores)
.where(eq(scores.gameId, ctx.params.gameId))
.orderBy(desc(scores.points))
.limit(10));// app.tsx
const { rows } = useSync("leaderboard", { params: { gameId } });
// rows: { id, name, points }[] — there is no .insert / .update / .remove hereNotes:
depsdrives change detection, falling back totablesand then to the entry name. A view over tables it doesn't share a name with must declare them, or it never re-broadcasts.implement()andview()are not interchangeable. Callingserver.implementon a name declared as a view throws, and so doesserver.viewon a name that isn't one.server.view(name, fn)takes no options — only the callback and the schema's dependency list. There is noauthorize,room,groupBy,count/countHintsorpkon a view. Do access control inside the callback (it getsctx.authandctx.params), and fall back to a regularimplement()with a throwingmutatewhen you need those knobs.- Rows need an
id. The primary key isn't configurable for views, so give each row a stableid— that's what delta diffing keys on. Computed rows can synthesize one. - The type-level block is React-only.
createSyncSvelte/createSyncVanilladon't narrow view entries, so a write there compiles and is refused at runtime instead.
Some apps need state that advances on a clock, not on user input — round timers, expiring claims, scheduled rotations. Pair server.interval with notifyChange and the server stays the single source of truth.
server.interval(500, () =>
server.lock("tick", async () => { // a tick must never outrun itself
const now = Date.now();
const active = await db.select().from(games).where(eq(games.mode, "pictionary"));
for (const g of active) {
if (g.state === "drawing" && now >= g.roundEndsAt) {
await endRound(g.id); // raw SQL writes
await server.notifyChange("games"); // fan-out to subscribers
}
}
}),
);server.interval(ms, fn) and server.timeout(ms, fn) wrap the globals with three
differences worth having: a throw or a rejected promise inside fn is caught and logged
instead of taking the process down, the handle is cleared by server.close(), and it is
disposed on bun --hot reload — so an edit-save loop doesn't leave a fleet of orphaned
timers ticking against the same rows. Both return { clear() }.
server.lock(key, fn) serializes async work per key: calls queue and run one at a time,
and a failure in one doesn't poison the queue behind it. server.tryLock(key, fn) is the
skip-if-busy variant — it returns null immediately when the key is held, which is
usually what you want for a tick that would otherwise pile up.
const result = await server.tryLock(`game:${gameId}`, () => scoreRound(gameId));
if (result === null) return; // another call is already scoring this gameBoth are in-process only. Across instances, keep the guard in the database (a
conditional UPDATE … WHERE state = 'drawing' that returns rows-affected) — the lock
protects a single Node/Bun process, not a cluster. The whiteboard example uses both —
see examples/whiteboard/server.tsx.
notifyChange per table gets tedious the moment one logical action touches three of
them. server.tx runs the work, tracks which tables it wrote, and fires one
notifyChange per touched table — only if the whole function succeeded.
await server.tx(async (tx) => {
await tx.update(games).set({ state: "scoring" }).where(eq(games.id, gameId));
await tx.insert(scores).values(rows);
await tx.delete(guesses).where(eq(guesses.gameId, gameId));
});
// → games, scores and guesses each broadcast once, after COMMIT- Atomic by default.
atomic: trueis the default: the body runs insideBEGIN/COMMITand rolls back on throw. It resolves an adapter fromServerConfig.txAtomic, falling back to a bundled Drizzle adapter (lazy-loaded, so there's no top-leveldrizzle-ormdependency). With neither available it throws — passatomic: falsefor a non-transactional group, or supply your own adapter withserver.tx({ atomic: myAdapter }, fn). - Table tracking is automatic for Drizzle only. The proxy watches
insert/update/delete(selectis not a write, so it doesn't count). On Kysely, Prisma or raw SQL, calltx.touch("games")after each write. - Notifies never fire on a throw, transactional or not.
- Pooled connections need care.
BEGIN/COMMITand the writes must share one connection, so pass a single-connection handle when atomicity is load-bearing rather than a pool.
For a single row there is server.emit(table, payload). It generates a rowId, stamps an
HLC, writes reflectdb's mirror plus the op-log entry, and broadcasts:
const { rowId, hlc } = await server.emit("todos", { title: "filed by a cron", done: false });
await server.emit("todos", { done: true }, { rowId, type: "update" });It does not call your implement's mutate, so it does not write your database.
That makes it the right tool when reflectdb's own store is what your query reads, and
the wrong one when your database is — a broadcast re-runs the query, so a row your
database never received simply won't appear. When your write has to happen under the
same stamp, use the primitive emit and server.rest() are both built on:
await server.applyServerOp(
{ type: "insert", table: "todos", rowId, payload },
async (stamped) => { // runs before the mirror write
await db.insert(todos).values({ id: stamped.rowId, ...stamped.payload });
},
{ roomKey: `org/${orgId}` }, // keep the fanout inside the tenant
);A throw inside execute aborts before anything touches the mirror or the op log. Omit
roomKey and the broadcast reaches every subscriber of the affected query, across rooms.
For large tables, sync a sliding window instead of the whole set:
const queries = defineSyncQueries({
messages: {
row: t<Message>(),
conflict: "lww",
countHints: true, // emit count_changed deltas
},
});
// React
const { rows } = useSync("messages", { window: 50 });
const total = useTotalCount("messages");
const loadMore = useLoadMore("messages");
// show "Load 50 more" when rows.length < totalMake the window a real limit by reading ctx.limit in the query and supplying a count:
server.implement("messages", {
query: ({ params, limit }, db) =>
db.select().from(messages)
.where(eq(messages.roomId, params.roomId))
.orderBy(desc(messages.createdAt))
.limit(limit ?? 1000),
count: async ({ params }, db) =>
(await db.select({ n: count() }).from(messages)
.where(eq(messages.roomId, params.roomId)))[0].n,
});Without them, the server fetches every matching row on every broadcast and slices in JS — pagination reduces bytes on the wire and nothing else. ctx.limit is undefined when the caller genuinely needs the full set, so a plain limit ?? <max> is safe.
A window is an entitlement, not a row count: a subscriber with window: 50 whose query matched 3 rows still receives the next 47 inserts, and loadMore(20) widens the entitlement by 20 regardless of how many rows actually arrived. Reconnecting restores the widened window, not the initial one.
server.rest() returns a fetch-style handler that responds to CRUD URLs derived from your schema:
const rest = server.rest({ prefix: "/api" });
serve({
port: 3001,
async fetch(req, srv) {
const url = new URL(req.url);
if (url.pathname.startsWith("/api/")) return rest(req);
// ...WebSocket upgrade, etc.
return new Response("ok");
},
});Endpoints generated for every implement()'d table:
GET /api/<table> → list (supports ?where=…&limit=&offset=)
GET /api/<table>/:id → single row
POST /api/<table> → insert (body = row, or array = batch)
PATCH /api/<table>/:id → update
DELETE /api/<table>/:id → delete
REST writes go through the same pipeline as sync writes and broadcast deltas to connected clients.
Share a Postgres op log between server instances. Clients reconnecting to a different instance resume seamlessly from their HLC watermark.
import pg from "pg";
import { createPostgresStorage } from "reflectdb/server";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const server = createSyncServer({
queries, db, transport,
storage: createPostgresStorage(pool),
serverId: process.env.FLY_ALLOC_ID,
poll: 500, // 500ms cross-instance poll for active-active
});| Mode | Config | Use case |
|---|---|---|
| Failover only | Shared Postgres, no poll |
Clients resume on reconnect |
| Active-active | Shared Postgres + poll: 500 |
Real-time cross-instance updates |
Each poll tick first probes the shared op log's head HLC; an idle tick costs one MAX(hlc) query and broadcasts nothing. Only tables that actually changed are re-broadcast. The tick also re-merges the shared clock watermark, so an instance whose wall clock lags its peers stops stamping writes below HLCs clients have already seen.
A complete React + Bun + Drizzle app that exercises most of reflectdb in one
place: examples/whiteboard/. It is deployed at
reflectdb-whiteboard.fly.dev.
cd examples/whiteboard
bun install
bun dev
# open http://localhost:3003 in two tabsTwo modes:
- Freeform draw — every player can draw on a shared canvas. Strokes are LWW per row.
- Pictionary — players take turns drawing while the others guess in chat. The server picks a word, runs a per-round timer, awards points based on remaining time, advances the drawer, and ends the game after N full rotations.
Rooms are ephemeral: 30 minutes after a room is created, a server-side sweep deletes it together with every stroke, chat line, player row and round secret belonging to it. Both tabs bounce back to the lobby when it happens.
What it demonstrates:
| Pattern | Where |
|---|---|
| Drizzle-typed schema, SQLite op log | schema.ts |
| WebSocket transport on Bun | server.tsx |
| Guest-only authentication via better-auth's anonymous plugin | auth.ts |
params-scoped queries (strokes, messages per game) |
server.tsx |
| Per-user query results — only the drawer receives the secret word | roundWord in server.tsx |
Server-side game loop with a mutex + notifyChange |
tick, withLock in server.tsx |
| Server-side guess detection (text replacement so the answer never broadcasts) | mutateMessageWithGuesses in server.tsx |
readonly field enforcement to keep the engine state out of client hands |
schema.ts |
Ephemeral cursors per game, scoped via key: \cursor:${gameId}`` |
app.tsx |
| Per-table rate limiting (loose for strokes, tight for chat) | server.rateLimit in server.tsx |
TTL sweep deleting whole rooms and their content out of band, with notifyChange turning it into client deletes |
sweepExpiredRooms in schema.ts |
| One-Machine Fly.io deployment, prebuilt bundle and env-driven config | Dockerfile / fly.toml / config.ts |
The included Fly.io config runs on one auto-stopping 512 MB Machine; deploying
your own copy takes two commands, both covered in
examples/whiteboard/README.md.
One ongoing Tetris game with no player cap: examples/tetris/.
Every visitor gets a live 10×20 well and a random server-assigned name. Players join
and leave without rounds or rooms; top out and that player's score resets to zero
before a fresh run begins immediately.
cd examples/tetris
bun install
bun dev
# open http://localhost:3004 in two tabs — each tab is a playerBun SQLite stores the authoritative wells and reflectdb sync log in one WAL database. The included Fly.io config runs on one auto-stopping 256 MB Machine.
| Pattern | Where |
|---|---|
Server-authoritative gravity — server.interval + server.tryLock |
server.tsx |
groupBy — one query execution for the global game instead of one per player |
players in server.tsx |
serverSet refreshing the player heartbeat |
players in server.tsx |
| Read-only board, piece, random name, and score fields | schema.ts |
Row ownership enforced with MutationError |
players.mutate in server.tsx |
view() leaderboard, recomputed from players |
standings in server.tsx |
| Headless game rules and top-out reset tests | game.ts / game.test.ts |
| Bun SQLite persistence and restart tests | database.ts / database.test.ts |
┌─────────────────────────────────────────────────────────────────────────────┐
│ SHARED CORE (core/) │
│ │
│ defineSyncQueries({ ... }) ── one schema, shared by every layer │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────────┐ │
│ │ types.ts │ │ hlc.ts │ │ schema.ts │ │
│ │ • SyncOp │ │ • HLC │ │ • SyncQueryDef │ │
│ │ • Messages │ │ • send/recv │ │ • InferRow / InferParams │ │
│ │ • ErrorReason│ │ • pack/cmp │ │ • t<T>() phantom helper │ │
│ │ • Protocol │ │ │ │ • ConflictPolicy │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────────────┘ │
└───────────────────────────────┬─────────────────────────────────────────────┘
┌───────────────┴────────────────┐
▼ ▼
┌───────────────────────────────────┐ ┌──────────────────────────────────────┐
│ SERVER (server/) │ │ CLIENT (client/) │
│ │ │ │
│ createSyncServer<TQueries>() │ │ createSyncClient<TQueries>() │
│ ├─ .implement(name, opts) │ │ ├─ .sync(name, params?) │
│ ├─ .view(name, fn) │ │ ├─ .insert/.update/.delete │
│ ├─ .auth(token → AuthContext) │ │ ├─ .subscribe / .subscribeTable │
│ ├─ .room(pattern, cb) │ │ ├─ .getRows / .getRow / .getState │
│ ├─ .rateLimit / .compaction │ │ ├─ .loadMore / .getTotalCount │
│ ├─ .rest({ prefix }) │ │ └─ .sendEphemeral / .subscribeEph. │
│ ├─ .notifyChange / .emit / .tx │ │ │
│ ├─ .lock / .interval / .timeout │ │ Internal: │
│ └─ .close() │ │ │
│ │ │ • SyncClient (state machine) │
│ Pipeline (per op): │ │ • ClientStore (row cache + queue) │
│ 1. clock drift check │ │ • OpCreator (HLC stamping) │
│ 2. rate limit (fail-open) │ │ │
│ 3. batch-size check │ │ State machine: │
│ 4. readonly enforcement │ │ hydrating → disconnected → │
│ 5. serverSet injection │ │ connecting → connected → │
│ 6. conflict resolution* │ │ bootstrapping → synced │
│ │ │ Storage adapters: │
│ (* skipped by eager modes) │ │ • memory (ephemeral) │
│ │ │ • indexeddb (persistent) │
│ BroadcastEngine (per write): │ │ │
│ group subscribers → run query │ │ │
│ once per group → diff per │ │ │
│ client → send → commit cache │ │ │
│ │ │ │
│ Op log storage (optional): │ │ │
│ • in-memory (default) │ │ │
│ • sqlite (bun:sqlite) │ │ │
│ • postgres (any pg-compatible) │ │ │
└───────────────────────────────────┘ └──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRANSPORT LAYER (transport/) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ WebSocket │ │ SSE │ │ Polling │ │
│ │ real-time │ │ event-stream + │ │ 3 HTTP endpoints — │ │
│ │ bi-directional │ │ POST back-chan │ │ works anywhere HTTP does│ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ FRAMEWORK BINDINGS (react/, svelte/, vanilla/) │
│ │
│ • createSyncReact(queries) → typed hooks + <SyncProvider> │
│ • createSyncSvelte(queries) → typed Svelte stores │
│ • createSyncVanilla(queries) → typed callback API │
└─────────────────────────────────────────────────────────────────────────────┘
reflectdb uses hybrid logical clocks (HLCs) to order events across machines without requiring synchronized clocks.
An HLC has three parts:
| Component | Purpose |
|---|---|
ms |
Physical wall time |
counter |
Logical counter (breaks ties) |
nodeId |
Machine that generated it |
HLCs pack to zero-padded strings (0000001711234567890.0003.client-abc), so string comparison gives correct causal ordering — no parsing needed. Conflict resolution is essentially free.
Two operations define the clock:
- send (
sendHlc): advancemax(wall, lastMs); increment counter on tie, else reset. - receive (
receiveHlc): advance past both local and remote state. Remote timestamps are clamped towall + MAX_CLOCK_DRIFT_MS(default 5 min) so a runaway client can't push the clock into the future.
The clock ratchets forward through every exchange, so causal ordering is preserved across the network.
Four built-in policies, chosen per-query:
defineSyncQueries({
posts: { row: t<Post>(), conflict: "lww" },
docs: { row: t<Doc>(), conflict: "merge" },
config: { row: t<Config>(), conflict: "server" },
scores: { row: t<Score>(), conflict: { policy: "custom", resolve: fn } },
});| Policy | Granularity | Concurrent edits to different fields | Use case |
|---|---|---|---|
lww |
Row | One wins, the other is lost | Simple data, rare conflicts |
merge |
Column | Both preserved | Collaborative editing |
server |
Row | Only first write; all others rejected | Config, reference data |
| custom | You choose | Your logic | Counters, "highest bid wins", business rules |
A custom resolver receives the incoming op, the existing row + per-column clocks, and metadata, and returns the resolved row. Throw to reject.
merge is a server-side guarantee: the server resolves per column using the client op HLCs held in its mirror, so two clients editing different fields both land. The per-column clocks a client sees on a broadcast are a different domain — a diff-driven broadcast can't attribute a column to the op that produced it, so every column changed in one broadcast carries that broadcast's HLC. Client-side merge orders broadcasts against each other and against local optimistic state; it does not reconstruct per-column causality between clients.
Both eager broadcast modes skip conflict resolution entirely — writes land last-writer-wins regardless of the declared policy.
1. client ──▶ hello server ──▶ hello_ack (protocol, serverId)
2. client ──▶ sync_declare server ──▶ snapshot / bootstrap_complete
3. client ──▶ ops (optimistic) server runs pipeline
4. server ──▶ ack / reject
5. server ──▶ delta (broadcast to subscribers)
6. client reconnects ──▶ resume (watermark HLC)
7. server ──▶ snapshot per changed query,
then resume_complete
All messages are JSON; the transport is just a pipe. WebSocket gives bi-directional real-time; SSE gives server-push with POST for upstream; polling is stateless HTTP for constrained environments.
reflectdb keeps its own store alongside yours, and it helps to know which one answers what:
| Read | Source |
|---|---|
Snapshots (bootstrap, resume) |
Your database, via the query callback |
| Broadcast deltas | Your database, diffed against a per-client cached result set |
Conflict resolution (lww / merge / server / custom) |
reflectdb's mirror — a JSONB row store plus per-column HLCs |
| Which tables changed since an HLC | reflectdb's op log |
A write therefore lands in two places: your mutate callback commits to your database, and reflectdb commits the mirror row plus its op-log entry. Those are separate commits — "atomic" in this codebase means the mirror row and its op-log entry commit together, not that they commit with your write. Consequences worth designing around:
- A crash between the two leaves your database ahead of the mirror. Clients still converge (snapshots come from your database), but conflict resolution decides against slightly stale state until the next write.
- If
mutatetransforms the payload, or database defaults/triggers rewrite it, or something writes the table out of band, the mirror drifts from what clients actually see. Keepmutatea faithful application ofop.payloadwhen conflict policy is load-bearing, and route out-of-band writes throughserver.applyServerOp/server.emit/server.tx.
Every accepted mutation is appended to the server's op log with its HLC. On reconnect, the client sends its last seen HLC as a watermark.
The server does not replay those ops to the client. It asks the log a single question — which tables changed since this HLC — and then re-executes the subscribed queries that depend on those tables, sending each result as a fresh snapshot, followed by resume_complete.
Re-running the query is what keeps resume honest: the client's own auth, params and room scoping are applied to what comes back, exactly as they were at bootstrap. A raw op replay would hand back rows the query itself would have filtered out.
Two details worth knowing:
- When at least one table has moved, queries depending on none of them are skipped. With no op log configured — or if the log lookup fails — every subscribed query re-runs instead.
- Adapters that implement
getChangedTablesSinceanswer with just the distinct table names. Without it, the handler falls back togetOpsSinceand reads the op rows only to collect those names, which is unbounded work for a client that has been away a long time.
Because the watermark is only an HLC and the log is the only shared state, cross-server failover is automatic when that log is shared (Postgres) — any instance can answer the question.
Old ops are compacted on a schedule based on client inactivity and minimum op age. A client whose watermark is older than the compaction cutoff gets resume_rejected with reason: "compacted" and falls back to a fresh bootstrap.
import {
defineSyncQueries, t, view, presence,
createHlc, sendHlc, receiveHlc, packHlc, unpackHlc, compareHlc,
MutationError, TransportSendError, isErrorReason, reasonFromError,
PROTOCOL_VERSION, MAX_CLOCK_DRIFT_MS, MAX_BATCH_SIZE,
TOMBSTONE_RETENTION_MS, SERVER_TOMBSTONE_RETENTION_MS,
} from "reflectdb/core";| Export | Description |
|---|---|
defineSyncQueries(map) |
Identity function that pins your schema's literal types. Feed its result to both server and client. |
t<T>() |
Phantom helper to declare a row or params type. Returns undefined as T. |
view({ row?, params?, deps?, tables? }) |
Declare a read-only computed query. Registered with server.view(); writes are blocked at the type level and rejected at runtime. See Read-only views. |
presence({ state?, params?, ttlMs? }) |
Declare a typed ephemeral channel. Read with usePresence() from createSyncReact. See Typed presence. |
createHlc(nodeId) / sendHlc / receiveHlc |
HLC constructors and transitions. |
packHlc / unpackHlc / compareHlc |
Serialize, deserialize, compare HLC values. |
MutationError(reason, message?) |
Throw from mutate/authorize to reject a write with a specific ErrorReason. |
TransportSendError(clientId, message) |
Throw from a custom ServerTransport.send when a frame did not reach the peer. |
isErrorReason(v) / reasonFromError(e) |
Validate / extract an ErrorReason. |
Types: HLC, SyncOp, OpType, OpStatus, ClientMessage, ServerMessage, ErrorReason, ConflictPolicy, ConflictResolver, SyncQueryDef, SyncViewDef, SyncPresenceDef, SyncQueryEntry, SyncQueryMap, InferRow, InferState, InferParams, InferWritableRow, RequiresParams, RateLimitConfig, CompactionConfig, ShapeConfig, AuthContext, DrizzleTableLike.
import {
createServer, createSyncServer,
createSqliteStorage, createPostgresStorage,
resolveConflict, processOp,
enforceClockDrift, enforceReadonly, enforceServerSet, enforceBatchSize, createRateLimiter,
MutationError,
} from "reflectdb/server";createSyncServer<TQueries, TDb, TAuth>(config) — the typed entry point. Returns a server with:
| Method | Purpose |
|---|---|
.implement(name, options) |
Register a query handler (required for every regular query in the schema). |
.view(name, fn) |
Register a read-only query declared with view(). No mutate; writes reject with readonly_query. |
.auth(callback) |
Validate the connection request and return an AuthContext. |
.room(pattern, callback) |
Scope clients to a subset of data, matched against URL-style patterns (org/:orgId). Return { ok: false, reason } to deny. |
.rateLimit(config) |
Set per-user/per-table limits. Fail-open on limiter errors. |
.compaction(config) |
Configure op-log compaction. |
.rest({ prefix }) |
Generate a CRUD fetch handler. |
.minSchemaVersion(n) |
Reject clients on older schema versions. |
.notifyChange(table, roomKey?) |
Manually trigger a broadcast (for external writes). |
.emit(table, payload, opts?) |
Server-origin row write: stamps an HLC, writes the mirror + op log, broadcasts. Does not call your mutate. Returns { hlc, rowId }. |
.applyServerOp(op, execute?, opts?) |
The primitive behind emit and rest. Hands the stamped HLC to execute before the mirror write; a throw aborts both. |
.tx(fn) / .tx(opts, fn) |
Run a write group in a transaction (atomic: true by default), tracking touched tables and firing one notifyChange each on success. |
.lock(key, fn) |
Serialize async work per key. .tryLock(key, fn) returns null instead of queueing when the key is held. |
.interval(ms, fn) / .timeout(ms, fn) |
Timers that log instead of crashing on a throw, and auto-dispose on close() and bun --hot reload. Return { clear() }. |
.reserveOpId(id) |
Idempotency gate — true when the id is fresh. Used to dedupe REST retries. |
.runCompaction() |
Manually run one compaction pass. |
.close() |
Shut down, disconnect clients, clear timers, close storage. |
See Server-driven game loops for interval / lock, and
Transactional writes with server.tx for tx,
emit and applyServerOp.
createServer() is the lower-level untyped variant — use it only if you need to register queries dynamically or don't have a defineSyncQueries map.
import {
createSyncClient,
SyncClient,
ClientStore,
createMemoryStorage,
createOpCreator,
} from "reflectdb/client";
import { createIndexedDBStorage } from "reflectdb/client/storage/indexeddb";createSyncClient<TQueries>(config) — fully typed client. Methods:
| Category | Methods |
|---|---|
| Lifecycle | init(), connect(), bootstrap(), resume(), push(), close() |
| Subscriptions | sync(name, params?), unsync(name) |
| Mutations | insert(name, id, payload), update(name, id, patch), delete(name, id), batch(ops) |
| Reads | getRows(name), getRow(name, id), getState(), getVersion(), getPendingCount() |
| Observation | subscribe(listener), subscribeTable(name, listener) (returns unsubscribe fn) |
| Windowing | loadMore(name, count), getTotalCount(name) |
| Ephemeral | sendEphemeral({ key, userId, data, ttlMs? }), subscribeEphemeral(key, listener) |
State machine: hydrating → disconnected → connecting → connected → bootstrapping → synced. Reconnects with exponential backoff (capped by maxReconnectDelayMs, default 30s).
import {
SyncProvider, useSyncClient,
useSync, useSyncStatus, useRow,
usePendingCount, useEphemeral,
useTotalCount, useLoadMore,
createSyncReact, derivePresenceKey,
} from "reflectdb/react";<SyncProvider> props:
| Prop | Type | Description |
|---|---|---|
url |
string |
WebSocket URL (required) |
token |
string |
Auth token passed to server.auth() (required) |
tables |
string[] |
Tables to auto-sync on mount |
clientId |
string |
Stable ID for this client (generated if omitted) |
storage |
ClientStorageAdapter |
Defaults to memory |
onReauth |
() => Promise<string> |
Called when server revokes auth |
onError |
(e) => void |
Connection / sync error callback |
Hooks:
| Hook | Returns |
|---|---|
useSync(table, options?) |
{ rows, insert, update, remove, loading } — options: { params?, includeDeleted?, window? } |
useSyncStatus() |
"hydrating" | "disconnected" | "connecting" | "connected" | "bootstrapping" | "synced" |
useRow(table, id) |
Single row or null |
usePendingCount() |
Total unsynced op count |
useEphemeral({ key, userId, ttlMs? }) |
{ events, broadcast } |
useTotalCount(table) |
Server-side count (requires countHints: true) |
useLoadMore(table) |
Function to expand the sync window |
createSyncReact<TQueries>(queries) returns the same hook set with row and param
types inferred from your schema, plus two things the bare hooks can't provide:
| Hook | Returns |
|---|---|
usePresence(name, params?) |
{ peers, set } for a presence() entry — peers is { userId, state }[], typed by the schema, and excludes you. Params are required when the entry declares them. |
useSync(viewName) |
{ rows, loading } for a view() entry — the mutators are absent from the type and stripped at runtime. |
derivePresenceKey(name, params) produces the same channel key usePresence uses, for
interoperating with useEphemeral or a non-React binding by hand.
import { createSyncStore, createSyncSvelte, createBrowserWsTransport } from "reflectdb/svelte";createSyncStore(config) returns a SyncStore:
const store = createSyncStore({ url, token, tables: ["notes"] });
const { rows, insert, update, remove } = store.sync<Note>("notes");
// rows is a Readable<Note[]> — subscribe with Svelte's $rows
store.status // Readable<SyncClientState>
store.pendingCount // Readable<number>
store.connect();
store.onStateChange((s) => …);
store.onError((e) => …);createSyncSvelte(queries) returns fully-typed store factories.
import { createSync, createSyncVanilla, createBrowserWsTransport } from "reflectdb/vanilla";
const sync = createSync({ url, token, tables: ["notes"] });
const notes = sync.sync<Note>("notes");
notes.onChange((rows) => render(rows));
notes.insert(id, { title: "…" });
sync.onStateChange((s) => …);
sync.onPendingChange((n) => …);
sync.onError((e) => …);
sync.connect();Also supports ephemeral: sync.sendEphemeral({ key, userId, data }), sync.onEphemeral(key, listener).
import { createWsServerTransport, isOriginAllowed } from "reflectdb/transport/ws";
import { createBunWsServerTransport } from "reflectdb/transport/bun-ws";
import { createSseServerTransport } from "reflectdb/transport/sse";
import { createPollingServerTransport, pollingBodyTooLarge } from "reflectdb/transport/polling";Each server transport returns a ServerTransport object plus framework-agnostic handlers (handleOpen, handleMessage, handleClose, handlePong for WS; handleSubscribe, handleMessage, handleDisconnect, createEventStream for SSE; handleConnect, handlePoll, handleSend, handleDisconnect for polling). Wire them to your HTTP server's routes — reflectdb does not ship a specific HTTP server.
reflectdb/transport/bun-ws is the same WebSocket transport shaped for Bun.serve: createBunWsServerTransport() returns { transport, websocket }, where websocket is the handlers object you pass straight to Bun.serve({ websocket }). Use it only under Bun; reflectdb/transport/ws is the runtime-agnostic one.
Client-side, use the createBrowserWsTransport(url) helper exported from reflectdb/svelte or reflectdb/vanilla, or let <SyncProvider> create one internally.
Every entry in defineSyncQueries({ ... }):
{
// declare the row type — choose ONE:
row: t<MyRow>(), // plain type (recommended default)
// OR
table: someDrizzleTable, // auto-infers row type + table list + pk
// optional:
params: t<{ orgId: string }>(), // typed query params (required on sync() if declared)
tables: ["posts", "post_tags"], // change-detection tables; defaults to the query key
pk: "id", // primary-key column name (default "id")
conflict: "lww", // "lww" | "merge" | "server" | { policy: "custom", resolve }
readonly: ["createdBy"], // fields the client cannot write
serverSet: ["createdAt", "updatedAt"], // fields the server always sets — required in `implement.serverSet`
countHints: true, // emit count_changed deltas for windowed sync
}conflict resolves the incoming op against reflectdb's mirror (its own JSONB row store and per-column clocks), not against your database. The two agree as long as every write goes through reflectdb and mutate persists the resolved payload verbatim — see Two stores, one sync.
A schema entry can also be a view or a presence channel instead of a regular
query. All three live in the same defineSyncQueries({ ... }) map:
import { defineSyncQueries, t, view, presence } from "reflectdb/core";
export const queries = defineSyncQueries({
todos: { row: t<Todo>(), conflict: "lww" }, // regular → server.implement()
leaderboard: view({ row: t<Score>(), deps: ["scores"] }), // → server.view()
cursor: presence({ state: t<{ x: number; y: number }>() }), // → no server call
});view({
row: t<MyRow>(), // row type (or omit for Record<string, unknown>)
params: t<{ gameId: string }>(),// typed params, same rules as a query
deps: ["games", "scores"], // change-detection tables
tables: ["scores"], // fallback when `deps` is absent
})deps → tables → the entry name, in that order, decides what re-runs the view. Views
have no conflict, readonly, serverSet or pk — they never accept a write.
Register with server.view(name, fn); server.implement on a view
name throws.
presence({
state: t<{ x: number; y: number; name: string }>(), // payload shape
params: t<{ gameId: string }>(), // folded into the channel key
ttlMs: 10_000, // entry expiry; omit for none
})Presence entries are ephemeral channels, not queries: there is nothing to register
server-side, nothing lands in the op log, and the fan-out is scoped by the sender's
active room. Read them with usePresence from createSyncReact(queries) — see
Typed presence.
createSyncServer({
queries, // from defineSyncQueries()
db, // optional — anything you want handed to query/mutate callbacks
transport, // ServerTransport (required)
storage, // StorageAdapter (optional; defaults to in-memory op log)
serverId: "server-1", // unique within the deployment
poll: 500, // ms — enable HA active-active polling
maxConnectionsPerUser: 10, // backpressure guard
queryTimeoutMs: 5_000, // abort a broadcast query that hangs (0 = disabled)
maxBroadcastConcurrency: 8, // subscriber groups queried in parallel per broadcast
allowAnonymous: false, // serve connections with no auth() callback
onEvent: (event) => { … }, // lifecycle telemetry
});A server with no auth() callback rejects the handshake — every message after hello would fail the authentication gate anyway. Pass allowAnonymous: true to serve unauthenticated clients; each session gets an anon:<clientId> identity.
server.implement("todos", {
query: (ctx, db) => /* fetch rows — return anything iterable */,
mutate: async (op, ctx, db) => { /* apply op.type/op.rowId/op.payload */ },
authorize: async (action, ctx, db) => {
// action.type is "read" | "write"
// throw to deny
},
serverSet: { createdAt: () => new Date() }, // required if schema declares serverSet fields
broadcast: "consistent", // "consistent" | "eager" | "eager-durable"
flushInterval: 50, // ms, for eager broadcasting
maxBufferSize: 100, // ops per eager batch
tables: ["todos"], // override change-detection set
count: (ctx, db) => db.count(…), // total row count for windowed queries
groupBy: ({ auth }) => String(auth.orgId), // collapse subscribers into one query execution
room: "org/:orgId", // require this room pattern on every subscription
});query may return whatever your data layer hands back, including live objects
your own code mutates later — an in-memory store, a game loop, an ORM's tracked
entities. Change detection snapshots each row when it caches it, so mutating the
same object in place is still seen as a change. The snapshot is shallow: mutating
a nested object inside a row is not, so treat nested values as immutable
(replace them rather than editing in place).
broadcast modes:
consistent(default): run the conflict pipeline, persist, then broadcast by diffing each subscriber's re-executed query result.eager-durable: skip conflict resolution; runmutate, persist to reflectdb's mirror atomically, then broadcast the delta directly. The recommended low-latency mode.eager: same, but the mirror write is batched in the background — a crash can lose it. Only safe whenmutateis durable to your own database.
Both eager modes still enforce readonly, serverSet, clock drift, the batch cap and rate limits. What they skip is conflict resolution: a declared conflict policy does not apply and writes land last-writer-wins.
A write re-executes each dependent query once per subscriber group. Groups default to (auth, params, roomKey), so with per-user auth they collapse to roughly one per connected client — N clients means N query executions per write.
groupBy returns the coarser key a query actually depends on:
server.implement("posts", {
query: ({ auth }, db) => db.select().from(posts).where(eq(posts.orgId, auth.orgId)),
// Results depend only on orgId — every member of an org shares one execution.
groupBy: ({ auth }) => String(auth.orgId),
});Two clients sharing a key must be entitled to byte-identical rows. Collapsing clients that aren't leaks rows across the boundary.
server.rateLimit({
opsPerSecond: 20,
opsPerMinute: 600,
batchesPerMinute: 120,
perTable: {
todos: { opsPerSecond: 10 },
},
ephemeralPerSecond: 60, // presence/cursor ceiling per client (default 60; 0 disables)
});The limiter is fail-open: if the limiter itself errors, ops still flow. Clients that exceed their limit receive ErrorReason: "rate_limited".
Ephemeral messages are metered separately and always — even without a rateLimit() call — because each one fans out to every room subscriber, which makes an unmetered channel an amplification vector. Dropped messages surface as an ephemeral_rate_limited event on onEvent.
server.compaction({
clientInactivityTimeout: "24h", // clients idle longer are ignored
interval: "1h", // compaction interval
minOpAge: "5m", // don't compact ops younger than this
});Durations accept ms, s, m, h, d.
createSyncClient({
queries,
clientId: "browser-xyz", // required; keep stable across reloads
transport: createBrowserWsTransport("ws://…"),
token: "auth-token", // required
storage: createIndexedDBStorage({ dbName: "app" }),
autoSync: true, // auto-sync param-less queries on connect
maxReconnectDelayMs: 30_000,
hydrateAllTables: false, // true = read every stored row at boot
onSync: () => { … }, // fires once bootstrap completes
onError: (e) => { … },
onReauth: async () => newToken, // called after "auth_revoked"
});At boot the client restores its persisted subscriptions first and hydrates only those tables — rows only ever reach local storage through a subscription, so nothing reachable is skipped. Set hydrateAllTables: true if you read rows for a table before calling sync() on it.
| Adapter | Import | Best for |
|---|---|---|
| (none) | omit storage |
In-memory op log; ephemeral, single node |
createSqliteStorage({ path?, db? }) |
reflectdb/server |
Single server, development, embedded — Bun only |
createPostgresStorage(poolOrConfig) |
reflectdb/server |
Multi-server HA, production |
createPostgresStorage accepts any object with query(text, values) => { rows } — pg.Pool, pg.Client, @neondatabase/serverless, etc. Optional config: { client, tablePrefix: "_reflectdb" }.
createSqliteStorage is backed by bun:sqlite and is resolved lazily, so importing reflectdb/server on Node is fine — only calling createSqliteStorage there throws. On Node, use createPostgresStorage (or omit storage for the in-memory op log).
| Adapter | Import | Best for |
|---|---|---|
createMemoryStorage() |
reflectdb/client |
Testing, SSR, short sessions |
createIndexedDBStorage({ dbName, version?, migrate? }) |
reflectdb/client/storage/indexeddb |
Production browser apps |
Presence, cursors and typing indicators are stored separately from the op log — they never durably persist, and they have their own adapter.
| Adapter | Import | Best for |
|---|---|---|
| (none) | omit ephemeral |
In-process store; single node |
createRedisEphemeral({ client, subscriber?, prefix? }) |
reflectdb/server/ephemeral/redis |
Multiple instances behind a load balancer |
import { createRedisEphemeral } from "reflectdb/server/ephemeral/redis";
import Redis from "ioredis";
const commands = new Redis(process.env.REDIS_URL!);
// Subscribe mode blocks ordinary commands, so the bus needs its own connection.
const bus = new Redis(process.env.REDIS_URL!);
const server = createSyncServer({
queries,
db,
transport,
ephemeral: {
adapter: createRedisEphemeral({
client: commands,
subscriber: {
subscribe: (channel, onMessage) => {
bus.on("message", (c, m) => { if (c === channel) onMessage(m); });
return bus.subscribe(channel);
},
},
}),
},
});client needs one method — call(command, ...args), which ioredis has natively.
For node-redis or Bun, wrap it:
// node-redis
{ call: (cmd, ...args) => client.sendCommand([cmd, ...args.map(String)]) }
// Bun
{ call: (cmd, ...args) => client.send(cmd, args.map(String)) }Options: prefix (default reflectdb:eph), maxEntries (default 100_000,
fleet-wide), hashTtlSeconds (default 24h — a safety net so a crashed instance
can't strand entries forever). Omit subscriber to share state without a live
bus: peers then appear on join and after a sweep, but not as they move.
ephemeral.maxEntries on its own tunes the in-process store's ceiling
(default 10_000) without swapping the adapter.
Implement EphemeralAdapter (from reflectdb/server/ephemeral) to back
presence with something else. publish/subscribe are optional — an adapter
without them is a shared store with no live bus.
createWsServerTransport({
maxMessageBytes: 1_000_000, // reject larger frames
pingIntervalMs: 30_000, // heartbeat; 0 disables
pongTimeoutMs: 60_000, // close half-open connections
maxBufferedBytes: 8_000_000, // outbound backpressure ceiling; 0 disables
});Returns a ServerTransport plus handleOpen, handleMessage, handleClose, handlePong. Wire these to your HTTP server's WebSocket callbacks (see Quick Start). Use isOriginAllowed(req, ["https://app.example"]) in your upgrade handler for CORS.
createSseServerTransport({
replayBufferSize: 256, // Last-Event-ID replay window
});Two endpoints to wire: GET /sync/events/:clientId (SSE stream) and POST /sync/messages/:clientId (client → server).
createPollingServerTransport({
maxQueueLen: 1000,
idleTimeoutMs: 60_000,
reaperIntervalMs: 30_000,
maxMessageBytes: 1_000_000,
});Three endpoints: POST /sync/connect/:id, GET /sync/poll/:id, POST /sync/send/:id. Use the pollingBodyTooLarge() helper in your request handler.
ServerTransport.send must reject when the frame did not reach the peer — unknown or closed socket, full outbound queue, backpressure limit. The broadcast engine treats a resolved send as "this delta landed" and only then commits the client's cached result set; a transport that swallows failures makes the server believe a client holds rows it never received, and the divergence persists until reconnect. Throw TransportSendError from reflectdb/core so callers can distinguish delivery failures from bugs.
- Bun 1.0+
- Node.js 22+ and npm — dev tooling, and
bun run verify:node, which checks the published package works for Node consumers in both ESM and CommonJS
git clone https://github.com/TimMikeladze/reflectdb.git
cd reflectdb
bun install| Command | Description |
|---|---|
bun test |
Run the full test suite |
bun test --watch |
Watch mode |
bun test --coverage |
Coverage report |
bun run build |
Build with bunup (ESM + types) |
bun run type-check |
TypeScript strict check |
bun run lint |
Lint with oxlint |
bun run format |
Format with oxfmt |
bun run verify:exports |
Check the exports map against dist/, and type-check the emitted declarations without ambient Bun/React globals (run after build) |
bun run verify:node |
Install the packed tarball into a throwaway Node project and check every subpath imports, requires, and type-checks there under both export conditions (run after build; needs node + npm) |
The site at reflectdb.dev lives in landing/:
cd landing
bun install
bun run dev # vite dev server
bun run og # regenerate the social cards in landing/publicbun run og renders every card in landing/og/ with your local Chrome (set
CHROME_PATH if it lives somewhere unusual) and writes the PNGs the pages
reference:
| Card | Output | Size | Used by |
|---|---|---|---|
og/index.html |
public/og.png |
1200x630 at 2x | reflectdb.dev |
og/tetris.html |
public/og-tetris.png |
1200x630 at 2x | the Tetris demo — served from reflectdb.dev, since the Fly Machine sleeps |
og/whiteboard.html |
public/og-whiteboard.png |
1200x630 at 2x | the whiteboard demo, served from reflectdb.dev for the same reason |
og/github.html |
public/og-github.png |
1280x640 at 2x | this repository's social preview, uploaded by hand under Settings → Social preview |
Edit the HTML, not the PNGs. 1200x630 is the one ratio X, Facebook, LinkedIn, Slack, Discord, Telegram, Mastodon and iMessage all unfurl without cropping, and each card has to stay under 300 kB or WhatsApp silently downgrades it to a small thumbnail — the script renders at the largest scale factor that fits, and fails if a card is oversized or the wrong shape.
The site reports page views to a self-hosted Umami instance,
and does so only when it is configured to. landing/vite.config.ts injects the
tag into index.html at build time from these variables — with the website id
unset, nothing is injected and the page makes no third-party request, so a local
dev server or a fork builds and runs untouched:
| Variable | Required | Default | Purpose |
|---|---|---|---|
VITE_UMAMI_WEBSITE_ID |
yes, to enable | — | The site's id in Umami. Unset disables analytics entirely. |
VITE_UMAMI_SCRIPT_URL |
no | https://linesofcode-umami.vercel.app/script.js |
The tracker script, if you host Umami elsewhere. |
VITE_UMAMI_DOMAINS |
no | — | Comma-separated hostnames to count. Set it to reflectdb.dev to keep preview deployments and localhost out of the numbers. |
They are read at build time, so changing one in the Vercel project takes effect on the next deployment rather than the next request.
src/
├── core/ HLC, types, schema
├── server/ createSyncServer, pipeline, session, handler
│ │ broadcast-engine, result-cache, eager-buffer,
│ │ compaction-manager, replay-detector, ephemeral-manager
│ └── storage/ SQLite + Postgres adapters
├── client/ sync-client, store, ops, typed-client
│ └── storage/ memory + IndexedDB adapters
├── transport/ WebSocket (runtime-agnostic + Bun.serve), SSE, polling
├── react/ <SyncProvider>, hooks, typed factory
├── svelte/ createSyncStore, typed factory
└── vanilla/ createSync, typed factory
- Runtime: Bun
- Language: TypeScript (strict, ESM only)
- Build: bunup
- Test:
bun:test - Lint: oxlint
- Format: oxfmt
- CI: GitHub Actions (Ubuntu + macOS)
MIT