reflectdb
v0.3.0
Published
Real-time sync engine for TypeScript — a server database and offline-first browser clients stay in sync, with optimistic writes and typed queries.
Maintainers
Keywords
Readme
reflectdb
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 / S3)Table of Contents
- 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
- Sync with no database at all
- Serverless sync on Vercel
- htmx 4 bindings
- Whiteboard + Pictionary example
- Infinite Tetris example
- Multiplayer kanban example
- htmx todos example
- Architecture
- Core Concepts
- API Reference
- Configuration Reference
- Development
- License
Demos
| 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. |
| Multiplayer kanban | Open the board · source | A board whose entire durable state is an S3 bucket — no Postgres, no SQLite, no volume — running on Vercel functions. Shows leaseless optimistic concurrency and serverless SSE. Open two tabs and drag a card. |
| htmx 4 todos | Open it · source | htmx owns the DOM, reflectdb owns the data. Attributes point at reflect: actions instead of server routes, so the server renders no HTML at all — every fragment comes from the local store. Open two tabs, or stop typing and go offline. The list resets to its seed rows every minute. |
| 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. |
Tetris, the whiteboard and the htmx todos each run on one auto-stopping Fly Machine with no volume, so the first load after an idle period may take a moment. The kanban board has no machine to wake — it is Vercel functions and a bucket — but every board resets on a five-minute window, and the htmx todos reset every minute. All four keep their data intentionally ephemeral across deployments and Machine replacement.
Why reflectdb
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 (single-node), Postgres (HA), or an S3-compatible bucket (no database at all). Omit it and the op log is in-memory.
- React / Svelte bindings — use the core client directly if you prefer.
Features
- 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, Postgres, or S3-compatible object storage 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
- No database at all —
createObjectStorageruns a room with an S3-compatible bucket as the only durable store, group-committing one object per batch - Runs serverless — SSE in
serverlessmode answers each POST with the replies it produced, so sync works on Vercel, Lambda or Workers - Framework bindings — React hooks, Svelte stores, a vanilla-JS helper, and htmx 4 attribute bindings; 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
Use Cases
- 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
- Serverless deployments with no database to attach and no machine to keep warm
Installation
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 htmx.org@^4 # for reflectdb/htmx
bun add drizzle-orm # if you want auto-inferred row types from Drizzle tables
# Svelte + vanilla have no peer depsQuick Start
A complete sync server in ~30 lines. No ORM, no database — just plain types and an in-memory Map, handed to reflectdb as db.
1. Define your schema
// 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
},
});2. Create the server
// 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();
// `db` is whatever holds your data — an ORM handle, a pool, or a plain Map.
// It is not optional: reflectdb only runs a `query` callback when it has a
// `db` to pass it, so leaving it out makes every snapshot come back empty.
const server = createSyncServer({ queries, db: todos, transport, serverId: "s1" });
server.auth(async (req) => {
// validate req.headers.get("authorization")
return { userId: "user-1" };
});
server.implement("todos", {
query: (_ctx, db) => [...db.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); },
},
});3. Connect from the browser
// 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.
Recipes
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.
WebSocket sync with SQLite + Drizzle
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() },
});Typed params for multi-tenant queries
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.
Authentication and room-based access control
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.
Per-column merge for collaborative editing
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)Custom conflict resolvers
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 } };
},
},
},
});Validating client payloads
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 messages (cursors, presence, typing)
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.
Typed presence
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.
Per-user query results
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.
Read-only views
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.
Server-driven game loops
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.
Transactional writes with server.tx
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.
Windowed sync and pagination
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.
Auto-generated REST API
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 → deleteREST writes go through the same pipeline as sync writes and broadcast deltas to connected clients.
High availability with Postgres
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.
Sync with no database at all
createObjectStorage runs a room with an S3-compatible bucket as the only durable store — no Postgres, no SQLite, no volume. Works with AWS S3, Cloudflare R2, Tigris, MinIO and GCS.
import { createObjectStorage } from "reflectdb/server/storage/object";
const storage = createObjectStorage({
store: {
provider: "tigris", // or "aws" | "r2" | "minio" | "gcs"
bucket: process.env.S3_BUCKET!,
credentials: {
keyId: process.env.S3_ACCESS_KEY_ID!,
secret: process.env.S3_SECRET_ACCESS_KEY!,
},
},
roomId: "board-42",
});
const server = createSyncServer({ queries, db, transport, storage });
// Flush and release the lease so the next machine takes over immediately.
process.on("SIGTERM", () => storage.close().then(() => process.exit(0)));Row state is authoritative in memory and the bucket is durability only, so reads never touch the network. A write appends to a buffer that group-commits one object per batch, then advances a compare-and-swapped manifest — the log's single linearization point. The flush loop is self-clocking, so there is no flush interval to tune, and an idle room issues no requests at all.
The trade is that a room has exactly one writer, elected with a lease. Route each room to one instance: this adapter replaces shared-database HA polling with room affinity rather than layering on top of it. Where you cannot promise that routing, see Serverless sync on Vercel.
Every knob and its default is in Object storage (no database); the design, the failure modes and the known limits are in docs/object-storage.md.
Serverless sync on Vercel
Serverless functions break two assumptions a long-lived server gets for free. Both have a flag, and examples/kanban is a deployed board that uses them.
Any request can land on any instance, so there is no single writer to elect. Drop the lease and let instances race on the manifest CAS instead:
const storage = createObjectStorage({
store: { /* … */ },
roomId,
concurrency: "optimistic", // no lease; the loser of a CAS re-reads and retries
});That rests on the same guarantee the lease mode does — the CAS is what keeps the data correct, and the lease was only ever an optimization. What it costs is that in-memory state is no longer authoritative, because another invocation may have committed since this one last looked. Call await storage.refresh() — one manifest GET, true when something moved — before a read that must be current.
A function cannot hold a WebSocket, and SSE is one-way. The POST and the event stream are two separate invocations, so a reply to a POST can never reach a stream that process does not own. serverless: true returns those replies in the POST's own response instead:
const transport = createSseServerTransport({ serverless: true });
const handler = new MessageHandler({ transport, serverId, db, allowAnonymous: true });
handler.setStorage(storage);
// POST /api/sync/messages — the replies this message produced
const messages = await transport.collectReplies(clientId, message, () =>
handler.whenIdle(clientId),
);
return Response.json({ messages });
// GET /api/sync/events — the stream carries only OTHER clients' changes
setInterval(async () => {
if (await storage.refresh()) await handler.pollRemoteChanges();
}, 250);MessageHandler is exported from reflectdb/server; the example drives it directly rather than through createSyncServer, because a serverless route needs whenIdle and pollRemoteChanges. Set the matching serverless: true on createSseClientTransport. Two more things a serverless deployment owns, both worked through in the kanban example: the client's session and subscriptions are rebuilt per invocation, because the hello and sync_declare went to a different process, and the stream instance must bootstrap so its result cache holds what the client is actually holding — the broadcast engine sends a diff against that cache, and an empty one makes every existing row look new.
htmx 4 bindings
reflectdb/htmx lets htmx drive the DOM while reflectdb owns the data. Bindings point at a reflect: action instead of a server route, so reads and writes resolve against the local store — optimistic, offline-capable, and re-rendered whenever a peer's change arrives. htmx 4 core ships no SSE or WebSocket support of its own; sync stays reflectdb's job.
import htmx from "htmx.org";
import { createHtmxSync } from "reflectdb/htmx";
const reflect = createHtmxSync({
htmx,
url: "ws://localhost:3001/sync",
token,
tables: ["todos"],
});
reflect.view<Todo>("todos", ({ rows }) =>
rows
.map(
(todo) => `
<li>
<input type="checkbox" ${todo.done ? "checked" : ""}
hx-put="reflect:todos/${todo.id}"
hx-vals='{"done": "${!todo.done}"}'>
${escapeHtml(todo.text)}
<button hx-delete="reflect:todos/${todo.id}">×</button>
</li>`,
)
.join(""),
);
// Form bodies arrive as strings — coerce before they reach the op log.
reflect.parse<Todo>("todos", (payload) => ({
...payload,
done: payload.done === "true",
}));
await reflect.connect();<ul hx-get="reflect:todos" hx-trigger="load" hx-swap="innerMorph"></ul>
<form hx-post="reflect:todos">
<input name="text" required>
<input type="hidden" name="done" value="false">
<button>Add</button>
</form>The action grammar is REST-shaped, so the attributes read like ordinary htmx:
GET reflect:<table> → render the collection view
GET reflect:<table>/:id → render one row
POST reflect:<table> → insert (row id from the body's `id`, else generated)
a body `id` names the row; it is not stored as a column
PUT reflect:<table>/:id → update
PATCH reflect:<table>/:id → update
DELETE reflect:<table>/:id → deleteWrites answer 204 No Content, so htmx swaps nothing where the write happened. The store change then re-renders every bound element a beat later — one render path whether the edit came from this tab, another tab, or the server.
Worth knowing:
- An element binds by making its first
reflect:read, so give collection bindingshx-trigger="load". - Use an inner swap (
innerHTML,innerMorph) on collection bindings.outerHTMLreplaces the bound element itself, and a replacement still carryinghx-trigger="load"would re-request forever. hx-swap="innerMorph"is usually what you want: htmx 4 morphs in place, so focus and caret position survive a re-render.- Query params reach the view for filtering (
reflect:todos?done=false); they do not change the server subscription. Declare that withtables, or withreflect.sync.sync(table, { params }). - A row read whose row is missing answers
204, leaving existing markup alone instead of blanking it. - A view returns a raw HTML string — escape interpolated values yourself.
- A bad action or an unregistered view answers 4xx/5xx, which htmx swaps nowhere by default, and a
reflect:request never reaches the network tab. The adapterconsole.errors every one of them so the failure is not silent. - Checkboxes, radios and
<option>s are re-synced from the rendered markup after a store-driven re-render. Once a user clicks one, the HTML spec stops letting thechecked/selectedattribute drive the property, and htmx's morph only fixes that up forvalue— so a peer's change would otherwise leave a ticked box on a row the store says is open.
Whiteboard + Pictionary example
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](./examples/whiteboard/app.tsx) |
| Per-table rate limiting (loose for strokes, tight for chat) | server.rateLimit in [server.tsx](./examples/whiteboard/server.tsx) |
| TTL sweep deleting whole rooms and their content out of band, with notifyChangeturning it into client deletes |sweepExpiredRooms in [schema.ts](./examples/whiteboard/schema.ts) |
| One-Machine Fly.io deployment, prebuilt bundle and env-driven config | [Dockerfile](./examples/whiteboard/Dockerfile) / [fly.toml](./examples/whiteboard/fly.toml) / [config.ts`](./examples/whiteboard/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.
Infinite Tetris example
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 |
Multiplayer kanban example
A shared board whose entire durable state is an S3-compatible bucket, deployed as
Vercel functions: examples/kanban/. It is live at
reflectdb-kanban.vercel.app. No Postgres,
no SQLite, no volume, no Redis.
cd examples/kanban
bun install
KANBAN_LOCAL_DIR=.data vercel dev
# open http://localhost:3000 in two tabs and drag a cardKANBAN_LOCAL_DIR swaps the bucket for a directory, so the example runs with no
credentials — the filesystem driver has the same CAS semantics and the whole
conformance suite runs against both. vite alone serves the UI but not /api,
so the board will not connect without vercel dev.
The board is open to anyone with the link and ?board=<slug> makes a new one.
Every board resets to its starting cards on a five-minute window, claimed with a
single If-None-Match: * write so exactly one of N racing invocations does the
work — a cron job would not run on Vercel's Hobby tier, and would leave an idle
board costing something.
| Pattern | Where |
|---------|-------|
| Object storage as the only durable store | createObjectStorage in lib/board.ts |
| concurrency: "optimistic" — no lease, instances race on the manifest CAS | lib/board.ts |
| Serverless SSE — replies returned from the POST that produced them | api/sync/messages.ts |
| storage.refresh() → handler.pollRemoteChanges() as the stream's poll loop | api/sync/events.ts |
| Rebuilding a client's session, subscription and result cache per invocation | restoreSubscription in api/sync/events.ts |
| Per-column merge so a rename and a drag on the same card both land | cards in lib/board.ts |
| Payload validation with MutationError rather than coercion | cards.mutate in lib/board.ts |
| Lazy periodic reset claimed with create-if-absent, applied through applyServerOp | lib/reset.ts |
| Fractional positioning for drag-and-drop ordering | schema.ts |
| Bundling the API routes so Vercel's Node builder never sees a .ts specifier | scripts/build-kanban.ts / vercel.json |
Deploy from the repository root rather than the example directory — the example
imports reflectdb from src/, and the root vercel.json runs a build that
bundles the two API routes into .vercel/output itself. Pushing to main does
it; the demo's Vercel project is linked to this repository at the repository
root. The variables to set, and the one extra storage.init() step MinIO needs,
are in examples/kanban/README.md.
htmx todos example
A todo list where htmx 4 owns the DOM and reflectdb owns the data:
examples/htmx-todos/. It is live at
reflectdb-htmx-todos.fly.dev. The
server renders no HTML — every fragment is produced in the browser from the
local store. The deployed list resets to its seed rows every minute.
cd examples/htmx-todos
bun install
bun run dev
# open http://localhost:3005 in two tabsThe server takes the first free port at or above PORT (default 3005), so it
does not collide with the other examples.
| Pattern | Where |
|---------|-------|
| reflect: actions on ordinary htmx attributes | index.html |
| One view function rendering the whole list from local rows | client.ts |
| parse building a patch, so a checkbox toggle keeps the text it never sent | client.ts |
| A counter riding along as an hx-swap-oob element | client.ts |
| Filters as query params, kept across peers' edits | index.html |
| A periodic reset routed through applyServerOp, so it reaches every open tab | server.ts |
Stop the server and keep typing: writes land in the DOM immediately, the badge counts them, and they drain on reconnect.
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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) │ │ │
│ • object (any S3-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/, htmx/) │
│ │
│ • createSyncReact(queries) → typed hooks + <SyncProvider> │
│ • createSyncSvelte(queries) → typed Svelte stores │
│ • createSyncVanilla(queries) → typed callback API │
│ • createSyncHtmx(queries) → typed views behind reflect: attributes │
└─────────────────────────────────────────────────────────────────────────────┘Object storage as the durable store
createObjectStorage replaces that op-log box with an S3-compatible bucket, and
nothing else in the diagram changes. The bucket is durability, never the read
path:
┌──────────────────────────────────────┐
clients ───────▶ │ writer instance (one per room) │
│ │
│ in-memory authoritative state │ ◀── every read
│ rows · per-column HLCs · op ring │ lands here
│ reserveOp set · meta │
│ │
│ write buffer ──▶ group commit │
└───────────────────┬──────────────────┘
│ one PUT per batch,
│ then one CAS
▼
┌──────────────────────────────────────┐
│ object store — S3 · R2 · Tigris · │
│ MinIO · GCS │
│ │
│ _lease writer election │
│ _manifest the CAS'd commit point │
│ wal/ immutable op batches │
│ snap/ materialized rows │
└──────────────────────────────────────┘Reads (getRow, getRows, getOpsSince, reserveOp) hit memory and never
touch the network. A write mutates memory and appends to a buffer; the buffer
flushes as one object per batch, and the commit is the compare-and-swap that adds
that segment to _manifest. Boot is one manifest GET, the newest snapshot, and
the segments the manifest still lists.
Only _lease and _manifest are ever overwritten, and only via CAS. Everything
else is write-once, which is what makes concurrent readers safe — and what lets
concurrency: "optimistic" drop the lease entirely on a platform that cannot
route a room to one instance.
Full design, provider compatibility, durability model and known limits:
docs/object-storage.md.
Core Concepts
Hybrid Logical Clocks
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.
Conflict Resolution
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.
The Sync Protocol
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_completeAll 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.
Two stores, one sync
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 — which only runs when db was passed to createSyncServer |
| 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
