Open Finapp
Справочник

Архитектура

Инициализация, структура проекта, паттерн хранилищ, авторизация - Supabase + PowerSync.

Инициализация приложения

запуск приложения
├── Плагины
│   ├── theme.ts (enforce: 'post')
│   │   └── Чтение темы из localStorage → обновление appConfig
│   └── powersync.client.ts
│       ├── navigator.storage.persist() - защита IndexedDB от вытеснения
│       ├── сессия сохранена? → ранний getPowerSyncDb().init() (параллельно с загрузкой приложения)
│       ├── setUploadErrorHandler → авто-согласование фатальных отказов загрузки
│       └── watch uid (immediate)
│           ├── uid есть → connectPowerSync(...)
│           └── auth определён, uid нет → disconnectPowerSync()
├── app.vue
│   ├── useHead() - CSS-переменные темы (tagPriority: -2)
│   ├── useGuard() - логика редиректа авторизации
│   └── рендер layout + page
└── useInitApp().initApp() (дефолтный layout, через useAsyncData)
    ├── primeStoresFromCache() - снимок localforage пользователя → мгновенная первая отрисовка
    ├── startWatches() - 5 подписок watchTable гидрируют хранилища
    │   ├── 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() - первая серверная синхронизация завершается в фоне (только онлайн)

Каждый watchTable срабатывает немедленно с текущими локальными строками SQLite и повторно при каждом изменении (локальная запись или входящая синхронизация). Отдельного шага «инициализация + подписка» нет.

Cache-first холодный старт

Загрузка идёт по принципу cache-first - сплеш-экрана нет:

  • Blob-кеш (app/app/composables/useStoreCache.ts): один localforage-блоб на пользователя (finapp.cache.<uid>) со снимком хранилищ из последней сессии. Он читается один раз при загрузке и наполняет все пять хранилищ до завершения db.init PowerSync и первых сканов SQLite. Записи - дебаунс-обновления (400 мс) затронутых срезов по схеме read-modify-write. Источником истины остаётся SQLite; блоб - только кеш для чтения.
  • Согласование с истиной: подписки перезаписывают данные из кеша строками SQLite. Пустая первая эмиссия сохраняет данные из кеша (на свежем устройстве первая синхронизация просто ещё не пришла); любая последующая пустая эмиссия - настоящая очистка и применяется.
  • Состояние загрузки (useInitApp.ts): единый computed bootState - 'ready' | 'onboarding' | 'error'. Дашборд показывает скелетон, резервирующий место (StatPageSkeleton), пока хранилища гидрируются; онбординг рендерится только когда первая синхронизация действительно завершилась и аккаунт пуст; экран ошибки (с повтором) - только когда синхронизация не удалась и локальных данных для отката нет.
  • Инструментирование: метки performance.measure холодного старта - cache:prime, ps:watch:<table>, trns:first-transform. См. Производительность.

Структура проекта

app/                        # @finapp/app - Nuxt, конфиг Supabase, конфиг PowerSync
  app/                      # Nuxt-корень (компоненты, composables, middleware, страницы, плагины)
  services/powersync/       # Клиентский слой данных: схема, db-синглтон, коннектор, трансформации, мутации
  supabase/                 # Конфиг Supabase: миграции, RLS-политики, powersync_setup.sql
  powersync/                # Self-hosted PowerSync: docker-compose, config/
docs/                       # @finapp/docs - сайт документации
i18n/locales/               # en-US.js, ru-RU.js

Неочевидные соглашения:

  • services/powersync/ - клиентский слой данных: SQLite-схема (AppSchema.ts), синглтон PowerSyncDatabase (db.ts), коннектор загрузки (connector.ts), конвертеры строк/элементов (transforms.ts) и хелперы записи (mutations.ts).
  • Каждая фича в app/app/components/<feature>/ содержит свой Pinia-стор, форму, список и типы - отдельной папки stores/ нет.
  • supabase/migrations/ содержит все изменения схемы Postgres. Никогда не редактировать вручную - использовать supabase migration new.

Паттерн хранилища

Все Pinia-хранилища (useTrnsStore, useWalletsStore, useCategoriesStore, useUserStore, useCurrenciesStore) следуют одному паттерну:

items: shallowRef<Record<id, item> | null>   # реактивное состояние (shallow для производительности)
подписка watchTable       # срабатывает при инициализации + каждой записи или синхронизации
save({ id, values })      # оптимистичная запись → upsertRow → откат при ошибке
delete(id)                # оптимистичная запись → deleteRow → откат при ошибке

Поток данных (запись)

действие пользователя
  → save({ id, values })
    → prev = снимок текущих items
    → оптимистично: items.value = { ...items, [id]: values }
    → await upsertRow(table, id, row)
      → успех: watchTable срабатывает → хранилище обновляется из SQLite
      → ошибка: items.value = prev + showErrorToast

Откат при ошибке записи обрабатывает ошибки локального SQLite. Отказ на стороне сервера (RLS, ограничение) обрабатывается отдельно обработчиком ошибок загрузки, который выполняет авто-согласование: отклонённые INSERT откатываются локально (sync.errors.uploadReverted), при отклонённых UPDATE/DELETE предлагается полная повторная синхронизация (sync.errors.uploadDiverged). См. Офлайн-first.

Поток данных (watchTable)

watchTable('SELECT * FROM trns', [], onRows, throttleMs)
  → срабатывает немедленно с текущими локальными строками
  → срабатывает снова при каждом изменении таблицы (локальная запись ИЛИ входящая синхронизация PowerSync)
  → onRows: rows → трансформация через rowToTrn() → items.value = новый объект-словарь

useTrnsStore использует reconcileTrns(prev, rows) внутри onRows: возвращает тот же prev-ref, если ничего не изменилось (подавляет эхо от собственной оптимистичной записи хранилища), и переиспользует неизменённые объекты строк по updatedAt, так что rowToTrn() выполняется только для изменённых строк.

Каскадное удаление

deleteWallet и deleteCategory также удаляют транзакции сущности из локальной SQLite перед возвратом (иначе подписка watchTable на trns вернёт их в UI после удаления родителя). Откат при ошибке восстанавливает и саму сущность, и её транзакции.

Демо-режим

Демо-режим полностью обходит PowerSync. Хранилища проверяют isDemo и пропускают все вызовы PowerSync. Вместо этого используются данные в памяти + localforage. Демо-данные (1000 транзакций, 18 категорий, 6 кошельков) генерируются в useDemo.ts. Управляется cookie finapp.isDemo.

Авторизация

Supabase Auth

app/app/composables/useSupabase.ts предоставляет:

  • Синглтон-клиент supabase-js (autoRefreshToken, persistSession, detectSessionInUrl: true).
  • Composable useSupabaseAuth() с реактивными session, uid, user, isAuthReady, а также signInWithPassword / signUp / signInWithGoogle / signOut.
  • Сессия сохраняется в localStorage и автоматически обновляется. onAuthStateChange поддерживает модульный реактивный ref session в актуальном состоянии.

Методы авторизации: email/password и вход через Google (signInWithOAuth, PKCE). detectSessionInUrl: true позволяет supabase-js обменять ?code= при возврате после OAuth: login.vue возвращает Google на /login, а после появления сессии переходит к исходному пункту назначения. Сессии Google - это обычные JWT Supabase, поэтому PowerSync (проверка через JWKS) и остальная auth-обвязка не меняются.

Защита авторизации

Авторизация использует два слоя:

  1. Route middleware (app/app/middleware/auth.global.ts): синхронная проверка сохранённой сессии Supabase в localStorage (hasPersistedSession(), app/app/composables/useAuthSession.ts), без сетевых запросов, работает офлайн. Перенаправляет на /login (сохраняя ?redirect=) при отсутствии; перенаправляет /login/dashboard при наличии сессии. Демо-режим обходит эту проверку. При возврате после Google OAuth сессия ещё не сохранена, поэтому /login?...&code= остаётся без редиректа, пока код обменивается на месте.
  2. Плагин PowerSync (app/app/plugins/powersync.client.ts): следит за Supabase uid и подключает/отключает PowerSync по мере появления или сброса сессии. Проверка читает localStorage напрямую, поэтому auth-cookie не записывается.

Защита от редиректов

getSafeRedirectPath() (app/utils/redirect.ts) валидирует параметры ?redirect= - разрешены только относительные пути, начинающиеся с / (не //), что предотвращает атаки открытого перенаправления.

Логика защиты

useGuard() в app.vue наблюдает за currentUser:

  • Авторизован + на /login → редирект на /dashboard (или путь из ?redirect=)
  • Не авторизован + не на /login → редирект на /login
  • Флаг isSigningOut предотвращает цикл редиректов при выходе

Защита локальной БД от чужого пользователя

connectPowerSync(client, powerSyncUrl, userId) проверяет сохранённый маркер владельца (finapp.psDbOwnerUid в localStorage). Если локальная SQLite принадлежит другому пользователю, база данных стирается перед подключением (fail-closed). Это предотвращает утечку данных при входе другой учётной записи на том же устройстве.

Поток выхода

signOut()
  → isSigningOut = true (предотвращение цикла редиректов)
  → supabase.auth.signOut()
  → disconnectPowerSync() - disconnectAndClear() стирает локальную SQLite + очищает маркер владельца
  → window.location.href = '/login' (жёсткая навигация, уничтожение всего JS-состояния)

Жёсткая навигация вместо Vue Router - см. Технические решения.

Курсы валют

  • Источник: Coinbase (база, фиат + крипта) + OpenExchangeRates (фиат сверху), сливаются в одну дневную строку source='merged' edge-функцией fetch-rates (pg_cron, 06:00 UTC)
  • Хранение: таблица rates в Postgres; глобальный поток PowerSync синхронизирует все строки каждому авторизованному пользователю
  • Фронтенд: useCurrenciesStore подписывается через watchTable('SELECT * FROM rates', ...)
  • Использование: getAmountInRate() конвертирует суммы в базовую валюту для статистики

PWA

  • Стратегия: generateSW из @vite-pwa/nuxt (без кастомного service worker)
  • Прекеширование: ассеты сборки плюс ровно один WASM-файл - wa-sqlite-async.<hash>.wasm, единственный вариант, который загружает воркер (остальные эмитируемые варианты - cipher/sync-сборки, которые приложение не использует); maximumFileSizeToCacheInBytes: 3 МБ, чтобы он не отбрасывался дефолтным лимитом в 2 МБ
  • navigateFallback: '/' отдаёт SPA-оболочку для всех навигаций (офлайн-поддержка)
  • Runtime-кеширование: Google Fonts, иконки Iconify (CacheFirst)
  • Start URL: /dashboard
  • Манифест: display: 'standalone'

Логирование

createLogger(prefix) (app/utils/logger.ts) - логирование только для разработки. В продакшене работает только .error(). Все операции хранилищ и авторизации инструментированы с префиксами ([wallets], [trns], [auth/middleware] и т.д.). Проверяйте консоль браузера при pnpm dev.

Управление модальными окнами

Модальные окна используют локальные ref и паттерн v-if + @close emit. Видимость меню - модульный ref в useMenuData.ts (общий для всех вызовов). Глобального реестра модалок нет - они размонтируются автоматически при размонтировании родительских компонентов (например, при выходе хранилища сбрасываются в null → условие layout становится false → модалки исчезают).

Персистентность сайдбара

Состояние показа/скрытия сайдбара на десктопе хранится в cookie (finapp.isShowSidebar), обеспечивая сохранение состояния между загрузками страниц.

Bottom Sheet

Мобильная форма транзакции использует кастомный компонент BottomSheet с useBottomSheetDrag - ручная реализация drag-поддержки touch и mouse. Функции: настраиваемый порог начала закрытия, определение направления, затухание оверлея, учёт скролла (игнорирует drag при прокрутке контента), исключение sort-хэндлов.

Краткая справка по ключевым файлам

ЧтоГде
Точка входаapp/app/app.vue
Логика темыapp/app/plugins/theme.ts, app/app/app.vue
Плагин PowerSyncapp/app/plugins/powersync.client.ts
Supabase-клиент + авторизацияapp/app/composables/useSupabase.ts
Синхронный гейт авторизацииapp/app/composables/useAuthSession.ts
Состояние загрузки / поток инициализацииapp/app/components/app/useInitApp.ts
Blob-кеш холодного стартаapp/app/composables/useStoreCache.ts
Защита авторизации (middleware)app/app/middleware/auth.global.ts
Логика защиты (app.vue)app/app/components/user/useGuard.ts
Утилиты синхронизации хранилищapp/app/composables/useStoreSync.ts
Синглтон PowerSync dbapp/services/powersync/db.ts
SQLite-схемаapp/services/powersync/AppSchema.ts
Коннектор загрузкиapp/services/powersync/connector.ts
Трансформации строк/элементовapp/services/powersync/transforms.ts
Хелперы записиapp/services/powersync/mutations.ts
Подавление эхаapp/app/components/trns/reconcile.ts
Расчёт статистикиapp/app/components/amount/getTotal.ts
Защита редиректовapp/utils/redirect.ts
Утилиты датapp/app/components/date/utils.ts
Логированиеapp/utils/logger.ts
Состояние менюapp/app/components/layout/useMenuData.ts
Bottom sheet dragapp/app/components/bottomSheet/useBottomSheetDrag.ts
Миграции Supabaseapp/supabase/migrations/
Правила синхронизации PowerSyncapp/powersync/config/sync-config.yaml

Следующие шаги

  • Синхронизация - как PowerSync синхронизирует данные между сервером и локальной SQLite
  • Офлайн-first - офлайн-записи, очередь загрузки и обработка ошибок
  • Производительность - бюджет холодного старта, ленивые чанки и инструментирование