Open Finapp
Reference

Validation Strategy

Client-only validation approach and trade-offs.

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.

LayerFrameworkPurpose
Client (browser)Zod v4Form UX - defaults, trimming, instant error feedback; runtime shape checks
Server (Supabase Postgres)Column types + RLSColumn types, NOT NULL, per-user ownership (RLS policy)

Where validation lives

Entity / useZod schema
Transaction formapp/app/components/trns/types.ts (trnItemSchema, transactionSchema) + app/app/components/trnForm/utils/validate.ts
Stat configapp/app/components/stat/useStatConfig.ts (ConfigSchema)
Date / query paramsapp/app/components/date/statDateParams.ts (queryParamsSchema)
User settingsapp/app/components/user/types.ts (userSettingsSchema)
Currency rates shapeapp/app/components/currencies/types.ts (ratesSchema)
Localeapp/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 NULL on required columns
  • RLS: every per-user table has (select auth.uid())::text = "userId" - rows from other users are invisible and writes are rejected
  • rates table is read-only for authenticated users (no client writes); rows are written server-side by the fetch-rates edge function
  • A trigger auto-creates user_settings on 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 CHECK constraints 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