Open Finapp
Reference

Architecture

Initialization flow, project structure, store pattern, auth - Supabase + PowerSync.

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's db.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 single bootState computed - '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.measure marks - 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), the PowerSyncDatabase singleton (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 separate stores/ directory.
  • supabase/migrations/ contains all Postgres schema changes. Never edit manually - use supabase 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-js client (autoRefreshToken, persistSession, detectSessionInUrl: true).
  • useSupabaseAuth() composable exposing reactive session, uid, user, isAuthReady, plus signInWithPassword / signUp / signInWithGoogle / signOut.
  • Session persists in localStorage and auto-refreshes. onAuthStateChange keeps a module-level reactive session ref 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:

  1. 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/dashboard when 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.
  2. PowerSync plugin (app/app/plugins/powersync.client.ts): watches the Supabase uid and 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
  • isSigningOut flag 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 the fetch-rates edge function (pg_cron, 06:00 UTC)
  • Storage: rates table in Postgres; global PowerSync stream syncs all rows to every authenticated user
  • Frontend: useCurrenciesStore subscribes via watchTable('SELECT * FROM rates', ...)
  • Usage: getAmountInRate() converts amounts to base currency for statistics

PWA

  • Strategy: generateSW from @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 MiB so 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.

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).

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

WhatWhere
App entryapp/app/app.vue
Theme logicapp/app/plugins/theme.ts, app/app/app.vue
PowerSync pluginapp/app/plugins/powersync.client.ts
Supabase client + authapp/app/composables/useSupabase.ts
Synchronous auth gateapp/app/composables/useAuthSession.ts
Boot state / init flowapp/app/components/app/useInitApp.ts
Cold-start blob cacheapp/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 utilsapp/app/composables/useStoreSync.ts
PowerSync db singletonapp/services/powersync/db.ts
SQLite schemaapp/services/powersync/AppSchema.ts
Upload connectorapp/services/powersync/connector.ts
Row/item transformsapp/services/powersync/transforms.ts
Write helpersapp/services/powersync/mutations.ts
Echo suppressionapp/app/components/trns/reconcile.ts
Statistics calcapp/app/components/amount/getTotal.ts
Redirect protectionapp/utils/redirect.ts
Date utilsapp/app/components/date/utils.ts
Loggingapp/utils/logger.ts
Menu stateapp/app/components/layout/useMenuData.ts
Bottom sheet dragapp/app/components/bottomSheet/useBottomSheetDrag.ts
Supabase migrationsapp/supabase/migrations/
PowerSync sync rulesapp/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