Skip to content

Quickstart

Copy page

This walkthrough boots a minimal notes app end-to-end: schema, mutators, sync rules, server, and TanStack DB client.

Terminal window
pnpm add @nizhal/kernel @nizhal/server @nizhal/db-collection @nizhal/cli
pnpm add drizzle-orm postgres zod
pnpm add @tanstack/react-db @tanstack/db @tanstack/offline-transactions

For web persistence, add @tanstack/db-wa-sqlite-persistence and wa-sqlite. For React Native, see React Native.

import { pgTable, text } from "@nizhal/kernel";
export const notes = pgTable("notes", {
id: text("id").primaryKey(),
owner_id: text("owner_id").notNull(),
body: text("body").notNull(),
});
export const schema = { notes };
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}`] };
},
),
});

One business operation = one mutator = one server transaction. clientMutationId (from the offline outbox) enables idempotent replay; the server returns client-id → server-id reconciliation when IDs differ.

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)),
],
}),
}));

defineSyncRules runs a no-leak lint: every data query must constrain rows to bucket keys. Rows outside the rule never reach a client.

nizhal.config.js
import { postgresStorage } from "@nizhal/server/adapters";
import { schema } from "./schema";
import { syncRules } from "./sync-rules";
export default {
db: process.env.DATABASE_URL,
schema,
syncRules,
};
Terminal window
nizhal migrate --config nizhal.config.js

postgresStorage.provision adds updated_at / deleted_at, change-tracking triggers gated by _nizhal_sync_control, tombstone tables, _nizhal_mutations for idempotency, and indexes — no logical replication.

Inspect raw SQL with buildPostgresProvisionPlan from @nizhal/server/adapters if needed.

import { createNizhalServer, bearerTokenAuth } from "@nizhal/server";
const server = createNizhalServer({
db: process.env.DATABASE_URL!,
schema,
mutators,
syncRules,
auth: bearerTokenAuth({ secret: process.env.JWT_SECRET! }),
});
server.listen(4000);

Endpoints:

  • POST /sync/pull — cursor-based delta
  • POST /sync/push — idempotent mutator batch
  • GET /sync/stream — WebSocket bucket pings
  • GET /nizhal/contract — OpenAPI/JSON-Schema artifact
import { createNizhalClient, nizhalCollectionOptions, createNizhalMutators } from "@nizhal/db-collection";
import { createCollection } from "@tanstack/react-db";
const echo = createNizhalClient({
server: "http://localhost:4000",
auth: {
getHeaders: () => ({ Authorization: `Bearer ${token}` }),
},
bucketsForSyncRule: (rule) => {
if (rule === "myNotes") return [{ ownerId: session.ownerId }];
return [];
},
});
const notesCollection = createCollection(
nizhalCollectionOptions({ name: "notes", syncRule: "myNotes", echo }),
);
const { mutate } = createNizhalMutators({ collections: { notes: notesCollection }, echo, mutators });
// Optimistic local write; outbox drains to /sync/push when online
await mutate.addNote({ id: crypto.randomUUID(), body: "Hello offline" });

On connect (and on each bucket ping), the collection pulls changes since its cursor. After reconnect, devices converge without a full refetch.

import { issueBearerToken } from "@nizhal/server";
const token = issueBearerToken({
userId: "user-1",
ownerId: "owner-1",
secret: process.env.JWT_SECRET!,
});

Run the server, open two browser tabs (or use apps/credit-ledger/examples/live-e2e.ts as a reference for a scripted check). Write offline in one tab, reconnect, and confirm the other tab converges via pull.