Offline-first
Finapp is offline-first via PowerSync. Local SQLite is the single source of truth. All reads come from SQLite; all writes go to SQLite first and are queued for upload to Supabase Postgres.
How It Works
user action (create/update/delete)
→ optimistic: items.value updated in store immediately
→ upsertRow / deleteRow writes to local SQLite (sync)
→ success: watchTable fires → store refreshes from SQLite
→ failure: rollback prev snapshot + showErrorToast (local write error)
→ PowerSync queues the op for upload
→ online: connector drains queue → Supabase upsert/update/delete
→ offline: queue persists in SQLite; drained on reconnect
There is no manual queue management, no collapsing rules, and no frontend-local ID system. PowerSync owns the upload queue.
Offline Writes
When the device is offline:
- Reads continue to work - all data is already in local SQLite.
- Writes go to local SQLite immediately (optimistic). The user sees the change right away.
- The upload op is added to PowerSync's internal queue.
- On reconnect, PowerSync drains the queue automatically.
No user action is needed to trigger sync after reconnect.
Session Loss While Offline
Going offline does not sign the user out. A failed token refresh while offline is retryable - supabase-js keeps the persisted session (localStorage) and emits no SIGNED_OUT, so the route guard (hasPersistedSession()) keeps the user in the app and the unsynced queue keeps filling. Two cases are handled so offline writes survive:
- Reactive session not resolved yet (offline cold start). After the access token expires, a fresh
getSession()resolves tonull, so the reactiveuidis null until reconnect. Writes stampuserIdviaresolveWriteUid(uid.value)(app/app/composables/useAuthSession.ts), which falls back to the synchronously-persisted uid. Without it the row would carry an emptyuserId, which RLS rejects (42501) on upload - silently dropping the offline write. - Session lost involuntarily (token revoked / expired server-side). Surfaces on reconnect as a genuine
SIGNED_OUT. The auth watcher then pauses sync (pausePowerSync-db.disconnect(), no clear) instead of wiping, keeping local SQLite and the unsynced queue. When pending writes exist, a re-auth toast is shown (sync.errors.sessionLostPending); re-authenticating as the same user reconnects and drains the queue. Only an explicit sign-out (useUserStore.signOut()) wipes local data viadisconnectPowerSync→disconnectAndClear.
Upload Error Handling
SupabaseConnector.uploadData() distinguishes fatal errors from retryable ones:
Fatal errors (discarded)
Postgres error classes 22xxx (data exception), 23xxx (integrity constraint violation), and 42501 (RLS - insufficient privilege) are treated as fatal. The failing op and every op queued after it are discarded so the queue is not blocked, then auto-reconciled by the plugin (planDivergence, app/services/powersync/uploadReconcile.ts):
- Rejected INSERTs: the local rows are deleted to converge (the server never accepted them). A reverted wallet/category cascades to local trns referencing it, so no orphans survive. Toast:
sync.errors.uploadReverted. - Rejected UPDATE/DELETE: the server still holds the prior version and PowerSync can't re-pull a single row, so the user is offered a destructive full re-sync (
forceResync: wipe local SQLite + re-pull server truth). Toast:sync.errors.uploadDiverged.
Retryable errors
Network errors, timeouts, and all other non-fatal errors cause the error to be rethrown. PowerSync catches the rethrow and retries with backoff when connectivity is restored.
Cascade Deletes
deleteWallet and deleteCategory also delete the entity's trns from local SQLite before returning. This is required because the watchTable subscription on trns would otherwise re-add the orphaned rows to the store after the parent entity is removed from SQLite. Rollback on a local write failure restores both the entity and its trns.
The server enforces no foreign key constraints (PowerSync upload order is not guaranteed), so referential integrity lives in app logic.
Optimistic Rollback
Every store write snapshots prev = items.value before mutating. If the local SQLite write throws, items.value = prev is restored and an error toast is shown. This covers local write errors (e.g., SQLite constraint, disk full). Server-rejection errors (RLS, constraint) are handled separately by the upload-error handler.
No Manual Queue
PowerSync owns the upload queue natively - there is no hand-written queue, no replay step, no op-merging, and no frontend ID remapping.
Client IDs are regular UUIDs (generated client-side via crypto.randomUUID() or equivalent). They are stable and serve as the permanent primary key on both device and server, so nothing needs remapping after upload.
Key Files
| File | Role |
|---|---|
app/services/powersync/connector.ts | SupabaseConnector.uploadData() - drains queue, fatal/retryable split |
app/services/powersync/mutations.ts | upsertRow, deleteRow - write to local SQLite |
app/services/powersync/db.ts | watchTable - subscribe to SQLite changes; pausePowerSync - stop sync, keep local data + queue |
app/app/composables/useAuthSession.ts | resolveWriteUid - persisted-uid fallback for row writes; hasPersistedSession route gate |
app/app/plugins/powersync.client.ts | Registers upload-error handler (toast); pauses sync (keeps data) on involuntary session loss |
app/app/components/trns/useTrnsStore.ts | Optimistic write + rollback pattern, cascade delete trns |
app/app/components/wallets/useWalletsStore.ts | Optimistic write + rollback + cascade delete trns |
app/app/components/categories/useCategoriesStore.ts | Optimistic write + rollback + cascade delete trns |
app/app/composables/useStoreSync.ts | showErrorToast, showSuccessToast, demo-only createDebouncedPersist |
Next Steps
- Sync - PowerSync sync streams, upload queue, and deletion handling
- Architecture - app initialization and store pattern