Supabase
Supabase is the primary backend platform for the Alhuda ERP. It provides the Postgres database, authentication, object storage, and the Deno-based Edge Functions runtime that hosts every server-side handler in the system.
Role in the stack
| Capability | How we use it |
|---|---|
| Postgres (v15) | Primary datastore. All domain tables (Booking, Payment, JournalEntry, ...) live under public. Every column access is gated by RLS using auth_user_has_permission('x.y') — see docs/PERMISSIONS.md. |
| Auth | Email/password + magic link. JWT sessions are persisted in localStorage and refreshed automatically. The frontend client is configured to use an in-tab processLock instead of the default navigatorLock. |
| Storage | User-uploaded documents (passport scans, invoice attachments, customer documents). Bucket access is controlled by RLS policies on storage.objects. |
| Edge Functions | Deno runtime, one folder per function under supabase/functions/. Used for privileged operations that need the service-role key (admin-users, mailer, razorpay-webhook, ...). |
Project structure
supabase/
config.toml # Local dev + function-level overrides
migrations/ # Timestamp-prefixed SQL migrations (188+ as of 2026-04-18)
functions/ # One subdirectory per edge function
_shared/ # Shared helpers (supabase.ts, auth.ts, whatsapp.ts)
razorpay-webhook/
mailer/
...
supabase/config.toml
Top-level fields configure the local dev stack (supabase start) — API port,
Postgres port, Studio port, auth settings.
The [functions.*] sections override platform-level behaviour per function.
The single knob we use repeatedly is verify_jwt = false, which disables the
gateway-level JWT check for functions that authenticate differently (public
webhooks, functions that parse the bearer themselves, or public helpers).
Example from supabase/config.toml:87-88:
The razorpay function is authenticated by HMAC signature, not JWT. See Razorpay for the signature protocol.
supabase/migrations/
Every schema change is a YYYYMMDDHHMMSS_description.sql file. Migrations must
be idempotent (IF NOT EXISTS, CREATE OR REPLACE, guarded inserts) so a
re-run against an already-migrated environment is a no-op. See
docs/operations/database-migrations.md for the full workflow.
supabase/functions/
Each function is a Deno module with an index.ts entrypoint and an optional
index.test.ts. Shared code lives in _shared/:
supabase/functions/_shared/supabase.tsexposesgetServiceClient()— the only blessed way to create a service-role client inside a function.supabase/functions/_shared/auth.ts— bearer-token validation helpers.supabase/functions/_shared/whatsapp.ts— WhatsApp Graph API helpers.
Frontend client setup
The app uses a single shared Supabase client created in
src/integrations/supabase/client.ts:17-32:
export const supabase: SupabaseClient = createClient(
SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY,
{
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
lock: processLock,
},
},
);
Two important choices:
- Untyped client. The
<Database>generic is intentionally omitted (client.ts:10-15) because the generated types file drifts behind the live schema. Narrow locally withas MyTypewhen you need it. processLockinstead ofnavigatorLock. The default cross-tabnavigatorLocktimes out under react-query polling + React strict-mode double-mount + Vite HMR and surfaces asNavigatorLockAcquireTimeoutError.processLockis an in-memory semaphore scoped to the current tab; cross-tab auth sync still works via thestorageevent.
Import it everywhere as:
Environment variables
The frontend reads two Vite-exposed env vars at build time:
| Variable | Purpose |
|---|---|
VITE_SUPABASE_URL |
Project URL, e.g. https://yzpfwdxpwalmfuodkxni.supabase.co |
VITE_SUPABASE_PUBLISHABLE_KEY |
Anon public key (safe to ship in the bundle) |
Copy .env.example to .env.local and fill in values from the Supabase
dashboard.
Anon key vs service-role key
The anon key is public and intended for the browser. The service-role
key must never appear in the frontend bundle. It lives only in edge
function secrets (supabase secrets set SUPABASE_SERVICE_ROLE_KEY=...)
and is fetched by getServiceClient() inside functions.
Typegen — src/integrations/supabase/types.ts
types.ts is the autogenerated Database type. It is produced with the
Supabase CLI:
Regenerate whenever a migration adds/removes a column, changes an enum, or renames a table. The file header states it is auto-generated — do not hand-edit.
Types drift
The shared client is deliberately untyped because generated types
historically fall behind. If you regenerate types.ts as part of a
migration PR, do it in a separate commit so reviewers can eyeball the
diff. Do not switch the client to createClient<Database>(...) without
aligning every consumer first.
Edge function conventions
- Runtime. Deno (not Node). Standard library imports come from
https://deno.land/std@<version>/...; npm-style imports viaesm.sh(e.g.https://esm.sh/@supabase/supabase-js@2). - Service client. Always import
getServiceClientfrom../_shared/supabase.ts. Do not hand-roll a service-role client in each function — centralising it makes secrets rotation a one-line change. - Env vars. Read with
Deno.env.get("NAME"). Guard missing required vars and return 500 with a clear log line (seesupabase/functions/razorpay-webhook/index.ts:382-386). - Raw body for HMAC. Read
await req.text()beforeJSON.parsewhen you need to verify a signature — the HMAC is over the exact request bytes. - Top-level
serve()guard. If you export helpers for tests, gate the listener sodeno testdoes not hang on an open port:
See supabase/functions/razorpay-webhook/index.ts:430-432.
- CORS. Return a preflight response for OPTIONS and echo
access-control-allow-* headers on every response.
- Idempotency. Any function that mutates data should tolerate being called
twice with the same input (unique keys, maybeSingle() dedup lookups).
Testing
Run a single function's tests with Deno:
Keep the unit test surface pure: exercise helpers directly (signature verification, event parsing) and stub the Supabase client for anything that would hit the database. Full integration tests against a live Supabase dev instance live in the Vitest suite.
Adding a new edge function
- Create
supabase/functions/<name>/index.tswith ahandleRequestexport and the guardedserve()call shown above. - If your function authenticates via something other than a Supabase JWT
(webhook signature, custom bearer, public helper), add a
[functions.<name>]block tosupabase/config.tomlwithverify_jwt = false. - Add an
index.test.tsalongsideindex.tscovering the auth check and the happy path. Rundeno testbefore committing. - If the function needs a new secret, document it in
docs/secrets-setup.mdand set it via:
!!! danger "Production secrets"
supabase secrets set writes to the linked Supabase project. Run
supabase link against the correct project (staging vs prod) before
touching secrets. Prefer rotating via the dashboard over the CLI when
you are not 100% certain which project is linked.
5. Deploy:
- If the function is called from the browser, add a typed helper in
src/lib/that invokes it viasupabase.functions.invoke('<name>', ...).