Validation Strategy
Current approach: client-only validation
Validation is client-only. There are no server-side business-rule validators. The server (Supabase Postgres) enforces only column types, NOT NULL constraints, RLS owner policies, and per-user sync rules.
| Layer | Framework | Purpose |
|---|---|---|
| Client (browser) | Zod v4 | Form UX - defaults, trimming, instant error feedback; runtime shape checks |
| Server (Supabase Postgres) | Column types + RLS | Column types, NOT NULL, per-user ownership (RLS policy) |
Where validation lives
| Entity / use | Zod schema |
|---|---|
| Transaction form | app/app/components/trns/types.ts (trnItemSchema, transactionSchema) + app/app/components/trnForm/utils/validate.ts |
| Stat config | app/app/components/stat/useStatConfig.ts (ConfigSchema) |
| Date / query params | app/app/components/date/statDateParams.ts (queryParamsSchema) |
| User settings | app/app/components/user/types.ts (userSettingsSchema) |
| Currency rates shape | app/app/components/currencies/types.ts (ratesSchema) |
| Locale | app/app/components/locale/types.ts (localeSchema) |
What each side validates
Zod (client):
.trim().min(1)- prevents empty strings after trim.positive()on amounts.default()for form initial values.transform()for query param parsing (string to number / boolean)- Discriminated union for transaction type (
trnItemSchema=transactionSchema | transferSchema) - Transfer same-wallet check:
incomeWalletId !== expenseWalletId
Supabase Postgres (server):
- Column types:
text,numeric,integer,boolean,jsonb NOT NULLon required columns- RLS: every per-user table has
(select auth.uid())::text = "userId"- rows from other users are invisible and writes are rejected ratestable is read-only for authenticated users (no client writes); rows are written server-side by thefetch-ratesedge function- A trigger auto-creates
user_settingson signup (no client-side bootstrap needed)
There are no server-side business-rule validators (no string length limits enforced server-side, no numeric range checks, no cross-field validation). Those rules live only in the Zod schemas on the client.
Trade-off
A determined client that bypasses the form can write arbitrary data to local SQLite, which PowerSync will upload to Supabase. Postgres will accept it as long as it passes column types and RLS. This is an accepted trade-off for a single-user personal finance app - the overhead of duplicating every business rule as Postgres constraints or Row-Level-Security policies was not justified.
If stricter enforcement is needed in the future, the options are:
- Postgres
CHECKconstraints for ranges and lengths - Supabase Edge Function as an upload proxy with server-side Zod validation
- Re-evaluating whether Supabase's built-in validation (pg_jsonschema, check constraints) covers the critical rules
Next Steps
- Technical Decisions - related architectural decisions and rationale
- Architecture - project structure and store pattern