PackagesDb

@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-transactions
npm install @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactions
yarn add @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactions
bun add @lunora/db @tanstack/db @tanstack/react-db @tanstack/offline-transactions

Quick 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.ts

The generator reads schema.ts and _generated/api.ts and wires each table:

  • reads from the table's list query,
  • scopeBy for sharded tables (from their shardBy),
  • writes from the mutation that calls ctx.db.insert("<table>", …) — attributed by behaviour, with toArgs mapped from the mutation's real arguments.

The result is a ready-to-use createCollections(client):

lunora/collections.ts (generated)
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:

  1. inserts the optimistic row immediately (so the UI updates with no latency),
  2. 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,
  3. supersedes the optimistic row with the real server row on acknowledgement (matched by id — see client ids),
  4. 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:

lunora/messages.ts
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 rollback

Each 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:

FrameworkImportHelper
ReactuseMutator from @lunora/reactuseMutator(handle)
VueuseMutator from @lunora/vueuseMutator(handle)
SolidcreateMutator from @lunora/solidcreateMutator(handle)
Sveltemutator from @lunora/sveltemutator(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 to row._id.
  • scopeBy? — a field that scopes the list (a shard key); makes the collection re-pointable via db.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's row / error directly 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.