Architecture
App Initialization
app starts
├── Plugins
│ ├── theme.ts (enforce: 'post')
│ │ └── Read theme from localStorage → update appConfig
│ └── powersync.client.ts
│ ├── navigator.storage.persist() - protect IndexedDB from eviction
│ ├── persisted session? → eager getPowerSyncDb().init() (overlaps app boot)
│ ├── setUploadErrorHandler → auto-reconcile fatal upload rejections
│ └── watch uid (immediate)
│ ├── uid present → connectPowerSync(...)
│ └── auth resolved, no uid → disconnectPowerSync()
├── app.vue
│ ├── useHead() - theme CSS vars (tagPriority: -2)
│ ├── useGuard() - auth redirect logic
│ └── render layout + page
└── useInitApp().initApp() (default layout, via useAsyncData)
├── primeStoresFromCache() - per-user localforage snapshot → instant first paint
├── startWatches() - 5 watchTable subscriptions hydrate the stores
│ ├── useCurrenciesStore - watchTable('SELECT * FROM rates', ...)
│ ├── useUserStore - watchTable('SELECT * FROM user_settings', ...)
│ ├── useWalletsStore - watchTable('SELECT * FROM wallets', ...)
│ ├── useCategoriesStore - watchTable('SELECT * FROM categories', ...)
│ └── useTrnsStore - watchTable('SELECT * FROM trns', ...)
└── awaitInitialSync() - first server sync settles in the background (online only)
Each watchTable fires immediately with current local SQLite rows and again on every change (local write or incoming sync). There is no separate init + subscribe step.
Cache-First Cold Start
The boot is cache-first - there is no splash screen:
- Blob cache (
app/app/composables/useStoreCache.ts): one per-user localforage blob (finapp.cache.<uid>) holding the last session's store snapshot. It is read once at boot and primes all five stores before PowerSync'sdb.init+ first SQLite scans finish. Writes are debounced (400 ms) read-modify-writes of touched slices. SQLite stays the source of truth; the blob is only a read cache. - Reconcile to truth: the watches overwrite primed data with SQLite rows. An empty first emission keeps the primed cache (the first sync just hasn't landed yet on a fresh device); any later empty emission is a genuine wipe and applies.
- Boot state (
useInitApp.ts): a singlebootStatecomputed -'ready' | 'onboarding' | 'error'. The dashboard shows a space-reserving skeleton (StatPageSkeleton) while stores hydrate; onboarding only renders once the first sync genuinely completed and the account is empty; the error screen (with retry) only when the sync failed and there is no local data to fall back to. - Instrumentation: cold-start
performance.measuremarks -cache:prime,ps:watch:<table>,trns:first-transform. See Performance.
Project Structure
app/ # @finapp/app - Nuxt source, Supabase config, PowerSync config
app/ # Nuxt app root (components, composables, middleware, pages, plugins)
services/powersync/ # Client data layer: schema, db singleton, connector, transforms, mutations
supabase/ # Supabase config: migrations, RLS policies, powersync_setup.sql
powersync/ # Self-hosted PowerSync service: docker-compose, config/
docs/ # @finapp/docs - documentation site
i18n/locales/ # en-US.js, ru-RU.js
Conventions worth knowing (not obvious from tree):
services/powersync/is the client data layer: SQLite schema (AppSchema.ts), thePowerSyncDatabasesingleton (db.ts), upload connector (connector.ts), row/item converters (transforms.ts), and write helpers (mutations.ts).- Each feature under
app/app/components/<feature>/owns its Pinia store, form, list, and types - there is no separatestores/directory. supabase/migrations/contains all Postgres schema changes. Never edit manually - usesupabase migration new.
Store Pattern
All Pinia stores (useTrnsStore, useWalletsStore, useCategoriesStore, useUserStore, useCurrenciesStore) follow the same pattern:
items: shallowRef<Record<id, item> | null> # reactive state (shallow for performance)
watchTable subscription # fires on init + every local write or incoming sync
save({ id, values }) # optimistic write → upsertRow → rollback on error
delete(id) # optimistic write → deleteRow → rollback on error
Data Flow (write)
user action
→ save({ id, values })
→ prev = snapshot of current items
→ optimistic: items.value = { ...items, [id]: values }
→ await upsertRow(table, id, row)
→ success: watchTable fires → store refreshes from SQLite
→ failure: items.value = prev + showErrorToast
Write failure rollback handles local SQLite errors. Server-side rejection (RLS, constraint) is handled separately by the upload-error handler, which auto-reconciles: rejected INSERTs are reverted locally (sync.errors.uploadReverted), rejected UPDATE/DELETE offers a full re-sync (sync.errors.uploadDiverged). See Offline-first.
Data Flow (watchTable)
watchTable('SELECT * FROM trns', [], onRows, throttleMs)
→ fires immediately with current local rows
→ fires again on every table change (local write OR incoming PowerSync sync)
→ onRows: rows → transform via rowToTrn() → items.value = new object map
useTrnsStore uses reconcileTrns(prev, rows) inside onRows: returns the same prev ref when nothing changed (suppresses the echo of the store's own optimistic write), and reuses unchanged row objects by updatedAt so rowToTrn() only runs for changed rows.
Cascade Delete
deleteWallet and deleteCategory also delete the entity's trns from the local SQLite DB (otherwise the watchTable subscription would re-add them after the parent is removed). Rollback on failure restores both the entity and its trns.
Demo Mode
Demo mode bypasses PowerSync entirely. Stores check isDemo and skip all PowerSync calls. In-memory data + localforage persistence is used instead. Demo data (1000 trns, 18 categories, 6 wallets) is generated in useDemo.ts. Controlled by the finapp.isDemo cookie.
Auth
Supabase Auth
app/app/composables/useSupabase.ts provides:
- A singleton
supabase-jsclient (autoRefreshToken,persistSession,detectSessionInUrl: true). useSupabaseAuth()composable exposing reactivesession,uid,user,isAuthReady, plussignInWithPassword/signUp/signInWithGoogle/signOut.- Session persists in localStorage and auto-refreshes.
onAuthStateChangekeeps a module-level reactivesessionref in sync.
Auth methods: email/password and Sign in with Google (signInWithOAuth, PKCE). detectSessionInUrl: true lets supabase-js exchange the ?code= on the OAuth return: login.vue sends Google back to /login, then navigates to the original destination once the session lands. Google sessions are ordinary Supabase JWTs, so PowerSync (JWKS validation) and the rest of the auth wiring are unchanged.
Auth Guard
Auth uses two layers:
- Route middleware (
app/app/middleware/auth.global.ts): synchronous gate on the persisted Supabase session in localStorage (hasPersistedSession(),app/app/composables/useAuthSession.ts), no network call, works offline. Redirects to/login(preserving?redirect=) when absent; bounces/login→/dashboardwhen a session is present. Demo mode bypasses this check. On the Google OAuth return the session is not persisted yet, so/login?...&code=is left in place (no bounce) while the code is exchanged. - PowerSync plugin (
app/app/plugins/powersync.client.ts): watches the Supabaseuidand connects/disconnects PowerSync as the session resolves or clears. The gate reads localStorage directly, so there is no auth cookie to write.
Redirect Protection
getSafeRedirectPath() (app/utils/redirect.ts) validates ?redirect= query parameters - only relative paths starting with / (not //) are allowed, preventing open redirect attacks.
Guard Logic
useGuard() in app.vue watches currentUser:
- Logged in + on
/login→ redirect to/dashboard(or?redirect=path) - Not logged in + not on
/login→ redirect to/login isSigningOutflag prevents redirect loop during sign-out
Cross-User Local DB Protection
connectPowerSync(client, powerSyncUrl, userId) checks a persisted owner marker (finapp.psDbOwnerUid in localStorage). If the local SQLite belongs to a different user, the database is wiped before connecting (fail-closed). This prevents data leaks when a different account logs in on the same device.
Sign-Out Flow
signOut()
→ isSigningOut = true (prevents auth guard redirect loop)
→ supabase.auth.signOut()
→ disconnectPowerSync() - disconnectAndClear() wipes local SQLite + clears owner marker
→ window.location.href = '/login' (hard navigation, destroys all JS state)
Hard navigation instead of Vue Router - see Technical Decisions.
Exchange Rates
- Source: Coinbase (base, fiat + crypto) + OpenExchangeRates (fiat overlay), merged into one daily
source='merged'row by thefetch-ratesedge function (pg_cron, 06:00 UTC) - Storage:
ratestable in Postgres; global PowerSync stream syncs all rows to every authenticated user - Frontend:
useCurrenciesStoresubscribes viawatchTable('SELECT * FROM rates', ...) - Usage:
getAmountInRate()converts amounts to base currency for statistics
PWA
- Strategy:
generateSWfrom@vite-pwa/nuxt(no custom service worker) - Precaching: build assets plus exactly one WASM file -
wa-sqlite-async.<hash>.wasm, the only variant the worker loads (the other emitted variants are cipher/sync builds the app never uses);maximumFileSizeToCacheInBytes: 3 MiBso it isn't dropped by the default 2 MiB cap navigateFallback: '/'serves SPA shell for all navigation (offline support)- Runtime caching: Google Fonts, Iconify icons (CacheFirst)
- Start URL:
/dashboard - Manifest:
display: 'standalone'
Logging
createLogger(prefix) (app/utils/logger.ts) provides dev-only logging. In production, only .error() fires. All store operations and auth flows are instrumented with prefixed logs ([wallets], [trns], [auth/middleware], etc.). Check the browser console during pnpm dev.
Modal State
Modals use local refs and v-if + @close emit pattern. Menu visibility is a module-level ref in useMenuData.ts (shared across callers). No global modal registry - modals unmount automatically when parent components unmount (e.g., on sign-out, stores reset to null → layout condition becomes false → modals disappear).
Sidebar Persistence
The desktop sidebar show/hide state is stored in a cookie (finapp.isShowSidebar), providing persistent state across page loads.
Bottom Sheet
The mobile transaction form uses a custom BottomSheet component with useBottomSheetDrag - a hand-written drag implementation supporting touch and mouse. Features: configurable start-closing offset, direction detection, overlay opacity fade, scroll-aware behavior (ignores drag when content is scrolled), sort handle exclusion.
Key Files Quick Reference
| What | Where |
|---|---|
| App entry | app/app/app.vue |
| Theme logic | app/app/plugins/theme.ts, app/app/app.vue |
| PowerSync plugin | app/app/plugins/powersync.client.ts |
| Supabase client + auth | app/app/composables/useSupabase.ts |
| Synchronous auth gate | app/app/composables/useAuthSession.ts |
| Boot state / init flow | app/app/components/app/useInitApp.ts |
| Cold-start blob cache | app/app/composables/useStoreCache.ts |
| Auth guard (middleware) | app/app/middleware/auth.global.ts |
| Guard logic (app.vue) | app/app/components/user/useGuard.ts |
| Store sync utils | app/app/composables/useStoreSync.ts |
| PowerSync db singleton | app/services/powersync/db.ts |
| SQLite schema | app/services/powersync/AppSchema.ts |
| Upload connector | app/services/powersync/connector.ts |
| Row/item transforms | app/services/powersync/transforms.ts |
| Write helpers | app/services/powersync/mutations.ts |
| Echo suppression | app/app/components/trns/reconcile.ts |
| Statistics calc | app/app/components/amount/getTotal.ts |
| Redirect protection | app/utils/redirect.ts |
| Date utils | app/app/components/date/utils.ts |
| Logging | app/utils/logger.ts |
| Menu state | app/app/components/layout/useMenuData.ts |
| Bottom sheet drag | app/app/components/bottomSheet/useBottomSheetDrag.ts |
| Supabase migrations | app/supabase/migrations/ |
| PowerSync sync rules | app/powersync/config/sync-config.yaml |
Next Steps
- Sync - how PowerSync syncs data between server and local SQLite
- Offline-first - offline writes, upload queue, and error handling
- Performance - cold-start budget, lazy chunks, and instrumentation