Self-host · no WAL · TanStack DB
The offline-first
sync engine you own.
Not a sync service — a sync API embedded in your own backend, on any Postgres, no logical replication. Cursor pull, idempotent push, declarative sync rules, and TanStack DB for the client store.
Read https://nizhal-docs.pages.dev/start.md then set up Nizhal for this project. import { defineMutator, defineMutators } from "@nizhal/kernel";
import { z } from "zod";
import { notes } from "./schema";
export const mutators = defineMutators({
addNote: defineMutator(
z.object({ id: z.string(), body: z.string() }),
async ({ tx, actor, newId }, args) => {
const id = args.id || newId();
await tx.insert(notes).values({ id, body: args.body, owner_id: actor.ownerId });
return { serverId: id, affectedBuckets: [actor.ownerId] };
},
),
}); import { defineSyncRules } from "@nizhal/kernel";
export const syncRules = defineSyncRules((b) => ({
myNotes: b.bucket({
parameters: () => b.params({ ownerId: "owner_id" }),
data: (bucket) => [
b.table("notes").where(b.eq("owner_id", bucket.ownerId)),
],
}),
})); import { createNizhalClient, nizhalCollectionOptions } from "@nizhal/db-collection";
import { createCollection } from "@tanstack/react-db";
const echo = createNizhalClient({
server: "http://localhost:4000",
auth: { getHeaders: () => ({ Authorization: `Bearer ${token}` }) },
bucketsForSyncRule: (rule) => [{ ownerId: session.ownerId }],
});
const notes = createCollection(
nizhalCollectionOptions({ name: "notes", syncRule: "myNotes", echo }),
); Define
Schema in Drizzle, mutators with Zod, sync rules that scope every row to the actor's buckets.
Sync
Provision Postgres with nizhal migrate, run createNizhalServer, wire TanStack DB collections on the client.
Ship offline-first
Writes complete locally with the network down; outbox drains on reconnect; devices converge via push + pull.
Define → Sync → Ship offline-first.
No logical replication
Cursor pull + sync_control triggers on any Postgres — RDS, Neon, Supabase. No wal_level change, no replication slot.
Your mutators, your invariants
One business op = one mutator = one server transaction. The mutator body is the merge function — not a black-box resolver.
Declarative sync rules
Parameters → buckets → scoped data queries. Server-evaluated, no-leak linted. Rows outside a rule never reach a client.
TanStack DB substrate
Live queries, optimistic writes, durable outbox — wa-sqlite on web, op-sqlite on React Native. Nizhal does not rebuild the client store.
Realtime as a hint
Bucket-scoped WS ping from the commit chokepoint. Miss a ping? The cursor pull is authoritative — convergence self-heals.
Self-host anywhere
Node, Bun, Cloudflare Workers + Durable Objects. One process + one Postgres. Export your data; no vendor lock.
Embed sync in your backend.
One Node/Bun process, one Postgres, TanStack DB on the client. No replication slot required.