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.

Or point your agent at it — it scopes & scaffolds:
Read https://nizhal-docs.pages.dev/start.md then set up Nizhal for this project.
How agent setup works →
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 }),
);
01

Define

Schema in Drizzle, mutators with Zod, sync rules that scope every row to the actor's buckets.

02

Sync

Provision Postgres with nizhal migrate, run createNizhalServer, wire TanStack DB collections on the client.

03

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.