@lunora/db
Optimistic, offline-first client data layer on TanStack DB.
@lunora/db turns your Lunora queries and mutations into a TanStack DB
data layer: live, indexed client collections for reads, and a durable, retried
offline outbox for writes. A sent message renders instantly, survives a
reload or an offline window, is superseded by the real server row on
acknowledgement, and rolls back if the server rejects it — without you
hand-writing any sync glue.
It sits on top of @lunora/client's transport (the same WebSocket subscriptions
and RPC the React hooks use). You keep your schema and functions exactly as they
are; @lunora/db binds them to collections.
Install
@lunora/db peer-depends on the TanStack packages, so install them alongside it:
pnpm add @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactionsnpm install @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactionsyarn add @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactionsbun add @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactionsQuick start (generated)
You don't have to write the binding by hand. With a schema and functions in
lunora/, generate it from them:
lunora codegen # ensure _generated/ is up to date
vis generate lunora-collections # writes lunora/collections.tsThe generator reads schema.ts and _generated/api.ts and wires each table:
- reads from the table's
listquery, scopeByfor sharded tables (from theirshardBy),- writes from the mutation that calls
ctx.db.insert("<table>", …)— attributed by behaviour, withtoArgsmapped from the mutation's real arguments.
The result is a ready-to-use createCollections(client):
import { defineCollections } from "@lunora/db";
import type { LunoraClient } from "@lunora/react";
import { api } from "./_generated/api.js";
import type { Doc, Id } from "./_generated/dataModel.js";
export const createCollections = (client: LunoraClient) =>
defineCollections(client, {
channels: {
list: api.channels.list,
insert: {
mutation: api.channels.create,
optimistic: (input: Omit<Doc<"channels">, "_id" | "_creationTime">, id) => ({
_id: id as Id<"channels">,
_creationTime: Date.now(),
...input,
}),
toArgs: (row) => ({ id: row._id, name: row.name }),
},
},
messages: {
list: api.messages.list,
scopeBy: "channelId",
insert: {
mutation: api.messages.send,
optimistic: (input: Omit<Doc<"messages">, "_id" | "_creationTime">, id) => ({
_id: id as Id<"messages">,
_creationTime: Date.now(),
...input,
}),
toArgs: (row) => ({ channelId: row.channelId, id: row._id, text: row.text }),
},
},
users: { list: api.users.list }, // read-only — no insert mutation
});Build it once (it owns the outbox, so keep a single instance) and use it in your components:
import { useLiveQuery } from "@tanstack/react-db";
import { createCollections } from "../lunora/collections";
const db = createCollections(client); // `client` from <LunoraProvider>
function Chat({ channelId }: { channelId: Id<"channels"> }) {
// Point the sharded `messages` collection at the active channel.
db.scope.messages({ channelId });
const { data: messages } = useLiveQuery((q) => q.from({ message: db.collections.messages }));
const send = (text: string) => db.actions.messages({ channelId, text, userId: me });
return; /* … */
}Reads — live, indexed queries
Each collection is a normal TanStack DB collection, so useLiveQuery gives you a
reactive relational layer that runs on the client: joins, filters, sorts and
aggregates, all maintained incrementally as deltas arrive. Collections are
autoIndexed, so these stay fast as data grows.
import { eq } from "@tanstack/db";
const { data } = useLiveQuery((q) =>
q
.from({ message: db.collections.messages })
.join({ author: db.collections.users }, ({ author, message }) => eq(message.userId, author._id), "left")
.orderBy(({ message }) => message.createdAt, "asc")
.select(({ author, message }) => ({ id: message._id, text: message.text, author: author?.name })),
);No extra server round-trip — the author name and ordering are derived from the two synced collections.
Writes — optimistic + durable outbox
Each insert binding produces an action under db.actions.<table>. Calling it:
- inserts the optimistic row immediately (so the UI updates with no latency),
- persists the write to a durable outbox (IndexedDB) and sends it via your Lunora mutation, retrying with backoff until it succeeds — so a write made offline is never lost and replays on reconnect,
- supersedes the optimistic row with the real server row on acknowledgement (matched by id — see client ids),
- rolls the optimistic row back if the server rejects the mutation (a validation or conflict error). Transient network/HTTP failures are retried, not rolled back.
const { id, transaction } = db.actions.messages({ channelId, text, userId });
// `id` is the client-generated row id; `transaction.isPersisted.promise`
// resolves when the write is confirmed (or rejects on rollback).
await transaction.isPersisted.promise;Surfacing rejected writes
Awaiting transaction.isPersisted.promise only works for the caller that
holds the transaction. A write made in one session and replayed in the next
(after a reload), or any fire-and-forget db.actions.* call, has no such
awaiter — so a permanent rejection would roll the optimistic row back with no UI
signal. Pass onWriteRejected to get an aggregate, fire-and-forget-safe channel
that fires once per permanently rejected write (a coded application error —
validation, RLS denial, conflict). Transient failures (offline, 5xx) are retried
by the outbox, not reported here:
const db = defineCollections(client, defs, {
onWriteRejected: ({ collection, row, error, code }) => {
toast.error(`Couldn't save your ${collection} change: ${error.message}`);
// `code` is the server's machine-readable reason (e.g. "CONFLICT") so you
// can branch; `row` is the optimistic row being rolled back (re-open a
// draft, etc.). The callback fires as the write is rejected — the row
// rollback follows it, so read `row`/`error` here rather than the
// collection's post-rollback state.
},
// Optional: a write whose target collection was removed/renamed in a deploy
// arrives here too, with `code: "UNKNOWN_MUTATION_FN"`.
onStorageFailure: ({ code, message }) => {
// The durable outbox couldn't persist (IndexedDB blocked in private mode,
// quota exceeded, …) — the write won't survive a reload. Warn the user.
toast.warning(`Your change may not be saved offline (${code}).`);
},
});onStorageFailure is the collection-layer counterpart to the standalone client's
offlineQueue.onPersistenceError; onLeadershipChange(isLeader) is also
available (informational — only the leader tab drains the outbox).
This is the same durable-outbox and reconciliation model @lunora/client
exposes directly — see Offline-first for the
lower-level persistence + queryCache options, the service-worker app-shell
recipe, and the connection-status APIs, if you want offline reads and writes
without adopting the full TanStack DB collection layer.
Scoped (sharded) collections
A scopeBy field makes a collection re-pointable — for a sharded
query like messages.list({ channelId }), call db.scope.<table>(args) to switch
which shard it syncs, or with no args to detach:
db.scope.messages({ channelId }); // sync this channel
db.scope.messages(); // detach (e.g. on unmount)When collections load
Each collection chooses when it starts syncing — together with scopeBy this
gives the full lazy / partial / eager load taxonomy declaratively:
defineCollections(client, {
// lazy (default): syncs on the first useLiveQuery subscriber
messages: { list: api.messages.list, scopeBy: "channelId" }, // + partial: only the scoped channel
// eager: syncs at boot — for small "instant" reference data you want warm
labels: { list: api.labels.list, load: "eager" },
});load: "eager" maps to TanStack DB's startSync. Even eager, a collection
pauses syncing while it has no subscribers (TanStack's gcTime lifecycle), so
it's "warm while referenced", not pinned in memory forever. load has no effect
on a scopeBy collection (nothing to sync until you scope it).
Client-generated ids
For the optimistic row and the persisted server row to reconcile by key, the
client must choose the row id up front. The insert binding generates a UUID,
hands it to optimistic (as the row's _id) and forwards it to the mutation via
toArgs — your mutation persists it with the validated clientId option:
import { v } from "@lunora/values";
import { mutation } from "./_generated/server";
export const send = mutation
.input({ channelId: v.id("channels"), id: v.optional(v.string()), text: v.string() })
.mutation(({ ctx, args: { channelId, id, text } }) =>
ctx.db.insert("messages", { channelId, text, userId: ctx.auth.userId }, id ? { clientId: id } : undefined),
);ctx.db.insert(table, doc, { clientId }) honours a UUID-shaped client id and
is validated for shape; uniqueness is still enforced by the primary key, so a
client can't collide with, overwrite, or forge a peer row. Without clientId,
Lunora mints the id as usual. See ctx.db.insert.
Shapes — partial replication
The insert/outbox path above syncs whole tables through each table's list
query. For partial replication
— replicating only the rows a client needs, scoped by a server-resolved predicate
— point a collection at a shape
instead of a list, via lunoraCollectionOptions:
import { lunoraCollectionOptions } from "@lunora/db/collections";
import { createCollection } from "@tanstack/db";
const { config, scope, checkpoints } = lunoraCollectionOptions({
client,
shape: { name: "messagesByChannel", args: { channelId } },
scopeBy: "channelId", // optional — makes the collection re-pointable
});
const messages = createCollection(config);
scope({ channelId: "general" }); // re-point a scoped collection (or call with no args to detach)lunoraCollectionOptions is the reusable core defineCollections is built on:
it returns { config, scope, checkpoints }. Pass exactly one of list (full
table) or shape (partial replication). The checkpoints registry resolves
optimistic-overlay drops against the server's confirmed watermarks — it's what
bindMutators uses below.
Custom mutators
For optimistic writes that run a local body first and a server-authoritative
impl second (rather than the insert-binding outbox), use the
custom-mutator
runtime on the @lunora/db/mutators subpath. Declare the optimistic body with
defineMutator (its serverRef names the server defineMutator it pushes to),
then bind the set to your client + collections:
import { bindMutators, defineMutator } from "@lunora/db/mutators";
const mutators = {
sendMessage: defineMutator<{ channelId: string; text: string }>({
serverRef: "messages:sendMessage", // the server-side defineMutator
apply: ({ collections }, { channelId, text }) => {
collections.messages.insert({ _id: crypto.randomUUID(), channelId, text });
},
}),
};
const send = bindMutators(client, { collections, checkpoints }, mutators);
// Calling a bound handle applies the optimistic overlay + pushes the server write.
const tx = send.sendMessage({ channelId: "general", text: "hi" });
await tx.isPersisted.promise; // resolves on confirm, rejects on rollbackEach call opens a TanStack DB optimistic transaction, runs apply against the
local collections, and pushes the authoritative write under a monotonic
per-client clientSeq. The rebase is free — TanStack re-derives pending overlays
over the latest synced base on every sync tick. Pass the checkpoints registry
from lunoraCollectionOptions so the overlay is held until the server echoes the
matching watermark (no flicker); omit it to drop the overlay as soon as the write
is accepted.
Framework hooks
The framework adapters wrap a bound handle in a small { mutate, pending, error, isError, reset } hook — reads stay on the existing useLiveQuery:
| Framework | Import | Helper |
|---|---|---|
| React | useMutator from @lunora/react | useMutator(handle) |
| Vue | useMutator from @lunora/vue | useMutator(handle) |
| Solid | createMutator from @lunora/solid | createMutator(handle) |
| Svelte | mutator from @lunora/svelte | mutator(handle) |
import { useMutator } from "@lunora/react";
const { mutate, pending, error } = useMutator(send.sendMessage);
// <button disabled={pending} onClick={() => mutate({ channelId, text })}>Send</button>pending is ref-counted across overlapping invocations of the same hook, so it
clears only once every concurrent call settles.
Manual binding
defineCollections(client, defs) is the underlying API. Each entry is:
list— the Lunora query that lists the rows (the sync source). Required.getKey?— row key extractor; defaults torow._id.scopeBy?— a field that scopes the list (a shard key); makes the collection re-pointable viadb.scope.<table>.load?—"eager"to sync at boot, or"lazy"(default) to sync on first use (see When collections load).insert?—{ mutation, optimistic, toArgs }to make the table writable through the outbox.
A CollectionDef may also carry onError? — notified when its list
subscription errors (the read side), distinct from onWriteRejected (the write
side) below.
An optional third argument carries layer-wide options:
onWriteRejected?— fires once per permanently rejected write, as it is rejected (the optimistic-row rollback follows) — so read the event'srow/errordirectly rather than the collection's post-rollback state (see Surfacing rejected writes).
It returns { collections, actions, scope, executor }. The row and action-input
types are inferred from each binding's list return and optimistic input, so
db.collections.* and db.actions.* are fully typed.
Advisor
Because the data layer keys writes off ctx.db.insert attribution, codegen runs
a table_without_insert advisory: an INFO nudge for any
schema table no function inserts into. It's a confirm-intent signal (the table
may be read-only, seeded by a migration, or written elsewhere), not an error.