Skip to content

First app

Copy page

The apps/credit-ledger reference app demonstrates Nizhal’s recommended domain shape: append-only movement ledgers where balances are folds, not stored mutable columns.

Offline merge is conflict-free when every write appends an immutable fact (+500 credit, -200 payment) and totals are derived. Two devices recording payments offline both append rows; the fold sums them — no lost update on a shared balance column.

  • shops — tenant root
  • shop_members — who can access which shop (membership drives buckets)
  • customers — per-shop contacts
  • ledger_entries — append-only movements (amount signed: credit positive, payment negative)

Balances are computed client-side:

function balance(entries: { amount: string }[]): number {
return entries.reduce((sum, e) => sum + Number(e.amount), 0);
}
import { defineSyncRules } from "@nizhal/kernel";
export const creditLedgerSyncRules = defineSyncRules((b) => ({
myShops: b.bucket({
parameters: (actor) =>
b.membership({
table: "shop_members",
where: { user_id: actor.userId },
select: { shopId: "shop_id" },
}),
data: (bucket) => [
b.table("customers").where(b.eq("shop_id", bucket.shopId)),
b.table("ledger_entries").where(b.eq("shop_id", bucket.shopId)),
b.table("reminders").where(b.eq("shop_id", bucket.shopId)),
],
}),
}));

b.membership resolves bucket parameters from a membership table with bound where values — no hand-escaped SQL. When a user is removed from shop_members, the next pull emits remove-ops and the client purges that shop’s rows locally (REQ-14).

recordCredit and recordPayment insert into ledger_entries inside one transaction. addCustomer returns { serverId, affectedBuckets } for client-id reconciliation.

recordCredit: defineMutator(recordCreditInput, async ({ tx, actor, newId }, args) => {
const shopId = requireShopId(actor);
const entryId = args.clientId || newId();
await tx.insert(ledgerEntries).values({
id: entryId,
shop_id: shopId,
customer_id: args.customerId,
amount: String(args.amount),
// ...
});
return { serverId: entryId, affectedBuckets: [shopId] };
}),

Jobs (SMS reminders) enqueue from mutators via jobs.schedule — see Jobs.

Collections per table, scoped to myShops:

const echo = createNizhalClient({
server: API_URL,
auth: { getHeaders: () => ({ Authorization: `Bearer ${token}` }) },
bucketsForSyncRule: (rule) =>
rule === "myShops" ? memberships.map((m) => ({ shopId: m.shop_id })) : [],
});

createNizhalMutators wraps kernel mutators with TanStack offline-transactions — optimistic UI, durable outbox, poison dead-letter on deterministic failures.

Terminal window
pnpm --filter credit-ledger dev # local PGlite smoke
pnpm --filter credit-ledger example:live # TCP server + real WS client
pnpm --filter credit-ledger example:neon # managed Postgres smoke (NEON_URL)
  • Multi-table mutator in one transaction (customer + entry + job)
  • Membership-scoped sync with revocation eviction
  • Offline write → reconnect → second device converges
  • Balance = fold invariant holds under concurrent appends