npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

reflectdb

v0.3.0

Published

Real-time sync engine for TypeScript — a server database and offline-first browser clients stay in sync, with optimistic writes and typed queries.

Readme

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](./examples/whiteboard/app.tsx) | | Per-table rate limiting (loose for strokes, tight for chat) | server.rateLimit in [server.tsx](./examples/whiteboard/server.tsx) | | TTL sweep deleting whole rooms and their content out of band, with notifyChangeturning it into client deletes |sweepExpiredRooms in [schema.ts](./examples/whiteboard/schema.ts) | | One-Machine Fly.io deployment, prebuilt bundle and env-driven config | [Dockerfile](./examples/whiteboard/Dockerfile) / [fly.toml](./examples/whiteboard/fly.toml) / [config.ts`](./examples/whiteboard/config.ts) |

The included Fly.io config runs on one auto-stopping 512 MB Machine; deploying your own copy takes two commands, both covered in examples/whiteboard/README.md.

Infinite Tetris example

One ongoing Tetris game with no player cap: examples/tetris/. Every visitor gets a live 10×20 well and a random server-assigned name. Players join and leave without rounds or rooms; top out and that player's score resets to zero before a fresh run begins immediately.

cd examples/tetris
bun install
bun dev
# open http://localhost:3004 in two tabs — each tab is a 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