Data Layer
Typed Supabase services, TanStack Query, and the subscription read-model.
The Electron template keeps data access small and replaceable: typed Supabase
calls live in app/services/, TanStack Query owns renderer cache behavior, and
zustand stores hold session and entitlement state that needs to be read across
routes.
Stack
| Layer | File | Role |
|---|---|---|
| Supabase client | app/lib/supabase/client.ts | createClient<Database>() with secure desktop session storage. |
| Database types | app/lib/supabase/database.types.ts | Hand-maintained Supabase type map used by service modules. |
| Query client | app/lib/query/client.ts | One global QueryClient with retry: 1, staleTime: 30000, and refetchOnWindowFocus: false. |
| Feature services | app/services/*.ts | Pure modules for Supabase tables, Storage, Edge Functions, analytics, and license activation. |
| Auth store | app/stores/auth-store.ts | Session, user, profile, auth-state subscription, and sign-out. |
| Subscription store | app/stores/subscription-store.ts | Account-bound entitlement read model when billing is enabled. |
Use services as the swap point for your own backend. Components should call a
service through a query, mutation, or store action instead of scattering
supabase.from(...) calls through UI code.
Todos example
Todos are an Example feature built to show the end-to-end pattern:
| Piece | File | Notes |
|---|---|---|
| Service | app/services/todos.ts | Lists, creates, toggles, and deletes rows from public.todos. |
| Query key | todosQueryKey(userId) in app/lib/query/client.ts | Keeps per-user cache entries separate. |
| UI | app/routes/showcase/demos/TodosDemo.tsx | Requires a signed-in user, uses useQuery and useMutation, and updates the cache on mutation success. |
| RLS | Supabase Setup SQL | Users can only select, insert, update, and delete rows where user_id = auth.uid(). |
listTodos() orders by sort_order and then newest created_at. Mutations
throw on Supabase errors so UI code can show localized failure toasts.
Profile and avatar example
Profile data uses app/services/profile.ts, while avatar uploads use
app/services/avatar.ts.
getProfile(userId)selects oneprofilesrow by id.updateProfile(userId, patch)writes profile fields and updatesupdated_at.uploadAvatar()validates PNG/JPEG/WebP and 5 MB max size, uploads toavatars/<user-id>/avatar.<ext>, then stores that path inprofiles.avatar_url.getAvatarUrl()converts the stored path into a seven-day signed Storage URL.removeAvatar()deletes the Storage object and clearsavatar_url.
These services depend on the inline schema and Storage policies from Supabase Setup.
Subscription read model
Billing uses a read model instead of trusting local purchase state:
| Piece | File | Rule |
|---|---|---|
| Table read | app/services/subscription.ts | Reads the signed-in user's subscriptions row with maybeSingle(). |
| Store | app/stores/subscription-store.ts | Derives free, active, expired, unknown, or disabled. |
| Product mapping | shared/price-config.ts | Maps Creem product IDs to free, pro, or lifetime. |
| Writes | Supabase Edge Functions | Clients never write subscriptions or payments; RLS grants SELECT only. |
When VITE_BILLING_ENABLED=false, the subscription store enters disabled
and does not read Supabase. When a session or network read fails, it uses
unknown, not free, so transient failures do not incorrectly downgrade paid
users or unlock paid features.
The subscriptions and payments shape matches the account-bound entitlement
read model used by the mobile templates' RevenueCat webhook, so teams that own
both can share one backend concept.
Add a feature
Add SQL and RLS
Create the table, indexes, triggers, and owner-scoped policies. For desktop client writes, always assume the renderer is public and let RLS enforce access.
Update database types
Regenerate or hand-edit app/lib/supabase/database.types.ts so services can
import Tables<'your_table'>, TablesInsert<'your_table'>, and
TablesUpdate<'your_table'>.
Create a service module
Put Supabase table, Storage, or Edge Function calls under app/services/.
Normalize errors there and keep component code focused on UI state.
Add query and mutation usage
Add stable query keys in app/lib/query/client.ts when shared keys are useful,
then call the service from useQuery, useMutation, or a small store action.
Render the route
Add the route, sidebar or command palette entry, i18n strings, and signed-out state. If the feature touches the main process, expose it through Conveyor instead of importing Electron APIs in the renderer.
Offline and network behavior
The current query client is simple: one retry, 30 seconds of freshness, and no window-focus refetch. It does not persist TanStack Query cache to disk.
app/hooks/use-online-status.ts wraps browser online and offline events
with useSyncExternalStore. This reflects the OS network interface, not a
guaranteed Supabase reachability check, and is best used as a warning signal.
For critical paid or account data, follow the subscription store's pattern:
represent transport failures as unknown so the UI can hold a neutral state
instead of pretending the user is free or entitled.
