Skip to content

Build a ledger app

Copy page

Tabkeep is the complete runnable app for this guide. It demonstrates the financially safe shape for an offline ledger without adding a custom client store, outbox, or reactivity layer.

Use one customer table and one append-only movement table. Store money as integer minor units:

export const ledgerEntries = pgTable("ledger_entries", {
id: text("id").primaryKey(),
shop_id: text("shop_id").notNull(),
customer_id: text("customer_id").notNull(),
kind: text("kind", { enum: ["credit", "payment"] }).notNull(),
amount: integer("amount").notNull(),
note: text("note"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

Do not store a mutable balance. Fold the synced entries whenever the UI needs it:

const balance = entries.reduce(
(sum, entry) => sum + (entry.kind === "credit" ? entry.amount : -entry.amount),
0,
);

This makes credits and payments durable facts. Reconciliation and audit work from the same history.

Tabkeep has three mutators: addCustomer, recordCredit, and recordPayment. Every input carries a client-generated id; the durable outbox supplies the clientMutationId used for idempotent replay. Both ledger mutators only insert:

recordCredit: defineMutator(input, async ({ tx, ownerId }, args) => {
await tx.insert(ledgerEntries).values({
id: args.id,
shop_id: ownerId,
customer_id: args.customerId,
kind: "credit",
amount: args.amount,
});
return { affectedBuckets: [ownerId] };
})

The same mutator updates the local collection immediately and runs authoritatively inside one server transaction when the outbox drains.

A narrow single-shop app needs one bucket:

export const tabkeepSyncRules = defineSyncRules((b) => ({
myShop: b.bucket({
parameters: () => b.params({ ownerId: "shop_id" }),
data: (bucket) => [
b.table("customers").where(b.eq("shop_id", bucket.ownerId)),
b.table("ledger_entries").where(b.eq("shop_id", bucket.ownerId)),
],
}),
}));

The rule is evaluated on the server and linted at startup so a row cannot escape its shop bucket.

A ledger entry is an append-only fact, so Tabkeep uses Nizhal’s default local-first mode. The web client gives both collections the same wa-sqlite persistence and durable outbox. React reads them with useLiveQuery; forms invoke the mutators. There is no network request in a component.

Run the end-to-end proof:

Terminal window
pnpm --filter @nizhal/example-tabkeep example:e2e

It proves offline credit, two-device convergence, idempotent replay, default-on audit, and the exact integer balance fold. Then run the app from apps/tabkeep and inspect each file—the small amount of application code is the point.