Technical Decisions
Conscious technical decisions and their rationale.
PowerSync offline-first (local SQLite as source of truth)
The app uses PowerSync to replicate Supabase Postgres into a local WASM SQLite database on the client. Stores read exclusively from that local SQLite via watchTable('SELECT * FROM ...', [], cb).
Why local SQLite as the source of truth:
This makes the app fully functional without network connectivity. Reads are instant (no round-trip). The single watchTable subscription fires immediately with current local rows AND again on every change - whether from a local write or an incoming server sync. This single mechanism replaces the old split between initial load and realtime subscription.
Why PowerSync over a custom offline queue:
The previous backend used a hand-written offline queue (components/offline/replay*) with XOR-hash deletion detection, local ID remapping, and paginated delta fetching. PowerSync handles all of that automatically: it maintains a local CRUD queue, drains it to Supabase via the connector's uploadData, and reconciles server changes via logical replication. No custom queue code, no hash mismatch logic, no ID remapping.
Why IDBBatchAtomicVFS (IndexedDB backend) instead of OPFS:
OPFS (Origin Private File System) is faster but requires SharedArrayBuffer, which requires COOP/COEP cross-origin isolation headers. Those headers break Google Fonts, Iconify CDN, and other third-party resources. IDBBatchAtomicVFS (IndexedDB-backed) needs no cross-origin isolation and works in a standard deployment.
Optimistic writes with rollback
Store methods update items immediately (optimistic), then call upsertRow/deleteRow to write to local SQLite. If the local write fails, the optimistic change is rolled back by restoring a prev snapshot, and an error toast is shown.
Why optimistic instead of await: Awaiting the SQLite write would block the UI, even though it is a local in-process operation. Optimistic updates give instant feedback. The rollback path handles the rare case where the local write itself fails (e.g., transaction conflict).
Note on server rejection: server-side errors (Postgres RLS violations, data exceptions) are surfaced separately by the connector's uploadData upload-error handler, not the local write catch.
shallowRef for store items
All Pinia stores use shallowRef<Map | null> instead of ref for the items collection. Store methods create new object references on every mutation to trigger reactivity.
Why:ref would deep-track every nested property of potentially thousands of transaction/wallet/category objects. shallowRef only tracks the top-level reference. The explicit "create new object" pattern on write is a deliberate trade-off: slightly more verbose stores, significantly lower reactivity overhead.
Client-generated UUIDs (no remapping)
Every new entity gets a UUID generated on the client (crypto.randomUUID() or equivalent) before the local write. That UUID is the permanent ID - the client owns it for the entire offline lifecycle and it is never remapped.
Why:
PowerSync queues a write locally and uploads it later, so the ID must be stable before the server ever sees the row. Supabase schema uses text PKs with default gen_random_uuid()::text, so whatever UUID the client generates is accepted as-is. No server round-trip to assign IDs, no remapping, no cascade updates to rows that reference a newly-created entity.
Why no FK constraints in Postgres: PowerSync upload order is not guaranteed. A transaction referencing a wallet may be uploaded before the wallet itself. FK constraints would reject valid data. Referential integrity is enforced by app logic instead.
SQLite views need INSERT/UPDATE, not upsert
PowerSync client tables are SQLite views backed by INSTEAD OF triggers. They accept plain INSERT and UPDATE but do NOT support INSERT ... ON CONFLICT (upsert syntax).
How upsertRow handles this:
It performs an existence check, then either INSERT (new row) or UPDATE (existing row), wrapped in a writeTransaction for atomicity. The caller always owns the ID, so INSERT/UPDATE is unambiguous.
reconcileTrns throttle and change suppression
useTrnsStore uses a 120ms throttle window on its watchTable (vs. the default 30ms for other tables). It also uses reconcileTrns to suppress the "echo" of its own optimistic write.
Why the larger throttle: Transactions are the largest table. During initial sync, PowerSync emits many rapid row-change events. The 120ms trailing coalesce folds the burst into a single re-query, reducing CPU.
Why reconcileTrns:
After an optimistic write the watch fires again with the same data (the echo of the write). reconcileTrns detects that nothing actually changed (comparing by updatedAt) and returns the same prev ref unchanged, suppressing a redundant reactivity cycle. It also reuses unchanged row objects so rowToTrn only runs for the delta.
Cross-user wipe before connect (fail-closed)
connectPowerSync checks a persisted finapp.psDbOwnerUid (localStorage). If local SQLite belongs to a different user, it calls disconnectAndClear() before connecting - wiping the local database first.
Why fail-closed: If the wipe itself fails, the owner marker is kept. The next different-user session will still attempt a wipe before reading any data. This prevents user A's data from leaking to user B on a shared device. Accepting a failed wipe and proceeding would be a data privacy bug.
Hard navigation on sign-out
Sign-out uses window.location.href = '/login' instead of Vue Router navigation.
Why:
Hard navigation destroys all JavaScript state - Pinia stores, WebSocket connections, reactive watchers, cached data. This prevents stale auth state from leaking into the next session. Vue Router navigateTo() would keep all in-memory state alive.
Safe arithmetic parser in calculator
The transaction form calculator uses a recursive descent parser (app/components/trnForm/utils/calculate.ts) instead of eval() or new Function().
Why:new Function(expression) is a code injection vector - a malicious string could execute arbitrary JavaScript. It also requires unsafe-eval in Content-Security-Policy. The custom parser supports only +, -, *, / on numeric literals, rejecting everything else.
Cascade deletes in stores
Postgres has no FK constraints (see above). When deleteWallet/deleteCategory is called, the store also deletes the entity's transactions from local SQLite (otherwise the watch subscription would re-add them on the next tick). Rollback restores both the entity and its transactions.
Why not rely on server cascade: There is no server-side cascade (no FK constraints). The client must clean up locally so the local SQLite view stays consistent with the intended state.
Computed children instead of denormalized childIds
Categories store only parentId. Child IDs are computed dynamically by filtering categories where parentId === id.
Why:
Previously, parent categories stored a childIds array. This required keeping both sides in sync on every reparent, create, or delete - a source of bugs. Computing children from parentId is a single source of truth with no sync issues. The category count is small enough (tens to hundreds) that the lookup cost is negligible.
Category nesting limited to 2 levels
Categories support only parent to child, not deeper nesting.
Why: Deeper nesting complicates the UI (statistics breakdown, category picker, breadcrumbs) without adding practical value for personal finance tracking. Two levels cover 99% of use cases (e.g., Food > Groceries).
Theme injection priority
Theme styles use tagPriority: -2 in useHead(), while plugin scripts use tagPriority: -1.
Why:
This ensures theme CSS variables are applied before the first paint. The priority chain: useHead styles (-2) - plugin scripts (-1) - @layer theme (always loses to non-layered). Non-layered CSS in theme.css would override useHead() by document order, which is why --ui-radius and --ui-primary must not be set there.
Next Steps
- Date Utilities - type-safe period handling, interval computation, formatting
- Validation Strategy - client-only validation with Zod
- Architecture - how decisions shape the project structure