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.
Why movement ledgers
Section titled “Why movement ledgers”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.
Schema sketch
Section titled “Schema sketch”shops— tenant rootshop_members— who can access which shop (membership drives buckets)customers— per-shop contactsledger_entries— append-only movements (amountsigned: 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);}Sync rules — membership buckets
Section titled “Sync rules — membership buckets”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).
Mutators
Section titled “Mutators”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.
Client wiring
Section titled “Client wiring”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.
Run it
Section titled “Run it”pnpm --filter credit-ledger dev # local PGlite smokepnpm --filter credit-ledger example:live # TCP server + real WS clientpnpm --filter credit-ledger example:neon # managed Postgres smoke (NEON_URL)What this proves
Section titled “What this proves”- 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