Skip to content

Repository files navigation

reflectdb

npm version npm downloads CI types license

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

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? }) => void

No 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/mutate callbacks hand you db untouched.
  • Your row types — plain TypeScript types, Drizzle $inferSelect, Kysely, Prisma, anything. Declare them with t<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 table at 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 resolutionlww, 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 RESTserver.rest() turns your schema into CRUD endpoints that broadcast deltas
  • Room-based access control — scope clients to org/:orgId or 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 allcreateObjectStorage runs a room with an S3-compatible bucket as the only durable store, group-committing one object per batch
  • Runs serverless — SSE in serverless mode 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 presencepresence() in the schema, usePresence() in the component, key derived for you
  • Read-only viewsview() entries that recompute on their dependencies and reject writes at both levels
  • Windowed sync — paginate large tables with loadMore + useTotalCount
  • Server-side toolkittx (transaction + auto-notify), lock / tryLock, and self-disposing interval / 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 reflectdb

Ships 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 deps

Quick 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 plain Error. Only MutationError carries an ErrorReason through to the client's onError; anything else is reported as server_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.view for them. They ride the same ephemeral channel and are room-scoped by the sender's active subscriptions.
  • peers excludes 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 clientId end to end — on the wire, in the server's store, and in peers — so two tabs from one login are two peers with two cursors. Put the display identity in state (as name above) if you need it; the authenticated userId rides along on every event for authorization and display.
  • Params are required when declared, exactly like useSyncusePresence("cursor") fails to compile if the entry declares params.
  • React only. createSyncSvelte / createSyncVanilla have no presence helper; use sync.sendEphemeral / sync.onEphemeral (or the store's ephemeral()) with your own key there. derivePresenceKey(name, params) is exported from reflectdb/react if 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 here

Notes:

  • deps drives change detection, falling back to tables and 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() and view() are not interchangeable. Calling server.implement on a name declared as a view throws, and so does server.view on a name that isn't one.
  • server.view(name, fn) takes no options — only the callback and the schema's dependency list. There is no authorize, room, groupBy, count/countHints or pk on a view. Do access control inside the callback (it gets ctx.auth and ctx.params), and fall back to a regular implement() with a throwing mutate when you need those knobs.
  • Rows need an id. The primary key isn't configurable for views, so give each row a stable id — that's what delta diffing keys on. Computed rows can synthesize one.
  • The type-level block is React-only. createSyncSvelte / createSyncVanilla don'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 game

Both 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: true is the default: the body runs inside BEGIN/COMMIT and rolls back on throw. It resolves an adapter from ServerConfig.txAtomic, falling back to a bundled Drizzle adapter (lazy-loaded, so there's no top-level drizzle-orm dependency). With neither available it throws — pass atomic: false for a non-transactional group, or supply your own adapter with server.tx({ atomic: myAdapter }, fn).
  • Table tracking is automatic for Drizzle only. The proxy watches insert / update / delete (select is not a write, so it doesn't count). On Kysely, Prisma or raw SQL, call tx.touch("games") after each write.
  • Notifies never fire on a throw, transactional or not.
  • Pooled connections need care. BEGIN/COMMIT and 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 < total

Make 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        → delete

REST 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}">&times;</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        → delete

Writes 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 bindings hx-trigger="load".
  • Use an inner swap (innerHTML, innerMorph) on collection bindings. outerHTML replaces the bound element itself, and a replacement still carrying hx-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 with tables, or with reflect.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 adapter console.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 the checked/selected attribute drive the property, and htmx's morph only fixes that up for value — 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 tabs

Two 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.

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 player

Bun 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 card

KANBAN_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 tabs

The 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): advance max(wall, lastMs); increment counter on tie, else reset.
  • receive (receiveHlc): advance past both local and remote state. Remote timestamps are clamped to wall + 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_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.

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 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 mutate transforms the payload, or database defaults/triggers rewrite it, or something writes the table out of band, the mirror drifts from what clients actually see. Keep mutate a faithful application of op.payload when conflict policy is load-bearing, and route out-of-band writes through server.applyServerOp / server.emit / server.tx.

The Op Log and Resume

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 getChangedTablesSince answer with just the distinct table names. Without it, the handler falls back to getOpsSince and 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.

API Reference

reflectdb/core

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.

reflectdb/server

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.

reflectdb/server/storage/object

import {
  createObjectStorage,
  createS3Driver, createFilesystemDriver, createMemoryDriver,
  PreconditionFailedError, BackpressureError, NotWriterError, MemoryLimitExceededError,
  IncompleteStateError, roomPrefix,
} from "reflectdb/server/storage/object";

createObjectStorage(config) — a storage adapter backed by an S3-compatible bucket. It satisfies the same StorageAdapter contract as the SQLite and Postgres adapters, plus a lifecycle and observability surface the design needs:

Member Purpose
init() Boot the room — manifest, snapshot, WAL replay. Idempotent, and implied by the first call to anything else; call it explicitly to surface boot failures at startup rather than on the first query. On MinIO it also seeds _lease and _manifest, which makes it a deploy step rather than something N servers race.
refresh() Fold in whatever another instance has committed. Returns true when the room actually moved. Costs one GET, and nothing further when commitSeq has not changed. Only meaningful under concurrency: "optimistic".
flush() Resolve once everything buffered is durable.
close() Stop accepting writes, flush, stop the flush loop, release the lease. Every step is bounded by shutdownFlushMs, so a hung store cannot hold a SIGTERM open.
health "healthy", "degraded" or "unavailable", with onHealthChange(cb) to observe transitions.
durableHlc Highest HLC known to be on the store, with onDurable(cb) fired per batch.

Errors are typed so a caller can tell "someone else moved first" from a transport failure: PreconditionFailedError (a CAS lost), BackpressureError (the write buffer hit batch.maxBufferBytes), NotWriterError (this instance lost or never held the lease), MemoryLimitExceededError (room state exceeded memory.maxRoomBytes), IncompleteStateError (the manifest names an object the store does not have — the room refuses to boot rather than present the loss as an empty room).

roomPrefix(roomId) returns the key prefix a room writes under, for tooling that has to address those keys from outside the adapter — an admin wipe, or a disposable room clearing itself after an IncompleteStateError.

Pass store and the S3 driver is built for you; pass driver to supply one directly:

Driver Signature Use
createS3Driver (config: StoreConfig) Any S3-compatible bucket. SigV4 over fetch with WebCrypto — no node:crypto, so the browser build stays clean.
createFilesystemDriver (rootDir: string) Local development and tests with no network. Same CAS semantics via atomic rename, but a filesystem cannot make compare-then-rename atomic across processes — single process only.
createMemoryDriver (options?) Tests, with fault injection (412 storms, 500s, latency) and casWildcard: false to reproduce MinIO.

See Object storage (no database) for the configuration, and docs/object-storage.md for the design.

reflectdb/client

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).

reflectdb/react

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.

reflectdb/svelte

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.

reflectdb/vanilla

import { createSync, createSyncVanilla, createBrowserWsTransport } from "reflectdb/vanilla";

const sync = createSync({ url, token, tables: ["notes"], onError: (e) =>  });
const notes = sync.sync<Note>("notes");

notes.onChange(() => render(notes.getRows()));
notes.insert(id, { title: "…" });

sync.onStateChange((s) => );
sync.onPendingChange((n) => );
sync.connect();

Also supports ephemeral: sync.ephemeral({ key, userId, ttlMs }) returns a binding with broadcast(data), getEvents() and onChange(listener).

reflectdb/htmx

import { createHtmxSync, createSyncHtmx } from "reflectdb/htmx";

const reflect = createHtmxSync({ htmx, url, token, tables: ["todos"] });

reflect.view<Todo>("todos", ({ rows, rowId, params }) => "<li>…</li>");
reflect.parse<Todo>("todos", (payload) => ({ …payload, done: payload.done === "true" }));

reflect.install();          // attach the reflect: shim (connect() does this too)
reflect.uninstall();        // detach it and drop every element binding
reflect.refresh("todos");   // re-render bound elements on demand
reflect.sync;               // the underlying VanillaSync, for anything attributes cannot express

await reflect.connect();

Requires htmx 4 — import the instance yourself and pass it in. The adapter listens for htmx:config:request and sets ctx.fetch on reflect: actions, so htmx still performs the swap, OOB handling, settling and history it would for a server response.

createSyncHtmx<typeof queries>() returns the same factory with view, parse and refresh narrowed to your schema's table names and row types. See htmx 4 bindings for the action grammar.

reflectdb/transport/*

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.

Configuration Reference

Query definition

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 definition

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
})

depstables → 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 definition

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.

Server configuration

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.

implement() options

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; run mutate, 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 when mutate is 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.

Scaling broadcasts with groupBy

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.

Rate limiting

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.

Compaction

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.

Client configuration

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.

Storage adapters

Server op log

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
createObjectStorage({ store, roomId }) reflectdb/server/storage/object S3-compatible object storage as the only durable store — no database

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).

Object storage (no database)

createObjectStorage runs a room with S3-compatible object storage as the only durable store. 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: "my-app",
    credentials: {
      keyId: process.env.AWS_ACCESS_KEY_ID!,
      secret: process.env.AWS_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 object store is durability only, so reads never touch the network. Writes group-commit: one PUT per batch, self-clocking, with no flush interval to tune. An idle room issues zero requests.

Because state is in memory and a room has exactly one writer, 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 — serverless functions, where any request lands on any instance — set concurrency: "optimistic". It drops the lease and lets instances race on the manifest CAS instead, with the loser re-reading and retrying. 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. In exchange, in-memory state is no longer authoritative, so call await storage.refresh() (one manifest GET) before a read that must be current.

Defaults are correct rather than fast: durability: "durable" acks a write only once it is on the store. "buffered" acks earlier and is lossy on crash until the durable-watermark protocol lands.

The SIGTERM handler above matters — without it a deploy drops whatever is buffered, and the next writer waits out lease.ttlMs before taking the room.

MinIO needs a one-time await storage.init() as a deploy step (it rejects the If-None-Match: * wildcard, so the room has to be seeded); on every other provider init() is a no-op.

store describes the bucket. provider fills in endpoint and urlStyle, so most deployments set three fields and nothing else:

Field Required Notes
bucket yes
credentials yes { keyId, secret, sessionToken? }.
provider no "aws", "r2", "tigris", "minio" or "gcs" — a preset for endpoint, region and urlStyle.
accountId for R2 Derives https://<id>.r2.cloudflarestorage.com.
endpoint / region / urlStyle no Override the preset. MinIO has no default endpoint, and a Fly-provisioned Tigris bucket uses fly.storage.tigris.dev rather than the preset's t3.storage.dev.
prefix no Key prefix, so one bucket holds many apps. Keys are <prefix>/rooms/<roomId>/….

Pass driver instead of store to supply a driver directly — createFilesystemDriver(dir) for local development with no credentials, createMemoryDriver() for tests.

Everything else, with its default:

Option Default Purpose
roomId Required. One room per adapter; it is the key prefix and the routing key.
writerId random Identifies this writer in the lease. Set it to make ownership legible in logs.
durability "durable" Ack after the manifest CAS. "buffered" acks on memory apply and is lossy on crash until the durable-watermark protocol lands.
concurrency "single-writer" "optimistic" drops the lease where a room cannot be routed to one instance. See Serverless sync on Vercel.
retentionMs Infinity How long accepted ops are kept.
batch.maxBytes 4 MiB Largest single WAL segment.
batch.minLingerMs 5 Coalesces ops arriving in the same event-loop tick. Not a flush interval — there is none, the flush loop is self-clocking.
batch.maxBufferBytes 64 MiB Buffer ceiling before the backpressure policy fires.
batch.onBackpressure "reject" Throw BackpressureError so backpressure reaches the client. "degrade" keeps accepting, stops promising durability, and flips health.
compaction.afterSegments 200 Snapshot once the manifest lists this many segments. Boot costs one GET per listed segment, so lower it for rooms that are read cold more often than they are written.
compaction.afterBytes 64 MiB Or this many bytes, whichever comes first.
compaction.gcGraceMs 3_600_000 Delay before deleting superseded segments, so a reader holding the old manifest does not 404.
lease.ttlMs / lease.renewMs 300_000 / 120_000 Long on purpose. close() releases the lease, so the TTL bounds only unclean failover — and a long one is what keeps an idle room free.
lease.mode "on-write" A room holding connected clients but taking no writes renews nothing.
memory.maxTotalBytes / memory.maxRoomBytes Infinity Global across rooms, then per room. State is authoritative in memory, so this is a cliff rather than a slope — set it and the ceiling arrives as MemoryLimitExceededError instead of an OOM.
memory.onExceeded "reject" "evict" and "spill" are accepted as configuration but throw at the limit; neither is implemented yet.
memory.idleEvictMs 300_000 Zero-client rooms flush, release the lease and drop their state.
shutdownFlushMs 5000 Bounds every step of close(), so a hung store cannot hold a SIGTERM open.
onDurable / onHealthChange Callbacks. The same values are readable as storage.durableHlc and storage.health.

Full design, the configuration reference and the known limits: docs/object-storage.md.

Client

Adapter Import Best for
createMemoryStorage() reflectdb/client Testing, SSR, short sessions
createIndexedDBStorage({ dbName, version?, migrate? }) reflectdb/client/storage/indexeddb Production browser apps

Ephemeral (presence)

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.

Transport configuration

WebSocket

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.

Server-Sent Events

createSseServerTransport({
  replayBufferSize: 256,        // Last-Event-ID replay window
  serverless: false,            // see below
});

Two endpoints to wire: GET /sync/events/:clientId (SSE stream) and POST /sync/messages/:clientId (client → server).

Serverless SSE

SSE is server→client only: normally a client POSTs a message and every reply — hello_ack, snapshots, op acks — comes back down the held stream. That works only while the POST and the stream are handled by the same process.

On Vercel, Lambda or Workers they are two separate invocations, so those replies would be enqueued onto a stream the POST's process does not have, and the client hangs at the handshake. Set serverless: true on both halves:

// server: return the replies the POST produced
const messages = await transport.collectReplies(clientId, message, () =>
  handler.whenIdle(clientId),
);
return Response.json({ messages });

// client
createSseClientTransport({ eventUrl, messageUrl, serverless: true });

The stream is then left doing the one thing it can: pushing other clients' changes. Leave it off for a single-process server — replies stream normally there, and turning it on would deliver each one twice.

Two things a serverless deployment must also handle, both shown in examples/kanban: the session is rebuilt per invocation (nothing remembers that this client subscribed), and the stream instance has to poll storage to notice writes made elsewhere.

HTTP long-polling

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.

Writing your own transport

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.

Development

Prerequisites

  • 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

Setup

git clone https://github.com/TimMikeladze/reflectdb.git
cd reflectdb
bun install

Scripts

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)

Landing page and social cards

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/public

bun 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/kanban.html public/og-kanban.png 1200x630 at 2x the kanban demo, so all four cards regenerate together
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.

Analytics

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.

Project Structure

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
│       └── object/  S3-compatible object storage — manifest CAS, WAL,
│                    snapshots, memory / filesystem / S3 drivers
├── 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
└── htmx/         createHtmxSync, reflect: action router, typed factory

Tech stack

  • Runtime: Bun
  • Language: TypeScript (strict, ESM only)
  • Build: bunup
  • Test: bun:test
  • Lint: oxlint
  • Format: oxfmt
  • CI: GitHub Actions (Ubuntu + macOS)

License

MIT

About

Real-time sync engine for TypeScript. Keeps a server-side database in sync with offline-first browser clients — optimistic writes, HLC conflict resolution, and end-to-end type inference over WebSocket, SSE, or polling.

Topics

Resources

Contributing

Stars

155 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages