Архитектура
Инициализация приложения
запуск приложения
├── Плагины
│ ├── 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.initPowerSync и первых сканов SQLite. Записи - дебаунс-обновления (400 мс) затронутых срезов по схеме read-modify-write. Источником истины остаётся SQLite; блоб - только кеш для чтения. - Согласование с истиной: подписки перезаписывают данные из кеша строками SQLite. Пустая первая эмиссия сохраняет данные из кеша (на свежем устройстве первая синхронизация просто ещё не пришла); любая последующая пустая эмиссия - настоящая очистка и применяется.
- Состояние загрузки (
useInitApp.ts): единый computedbootState-'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поддерживает модульный реактивный refsessionв актуальном состоянии.
Методы авторизации: email/password и вход через Google (signInWithOAuth, PKCE). detectSessionInUrl: true позволяет supabase-js обменять ?code= при возврате после OAuth: login.vue возвращает Google на /login, а после появления сессии переходит к исходному пункту назначения. Сессии Google - это обычные JWT Supabase, поэтому PowerSync (проверка через JWKS) и остальная auth-обвязка не меняются.
Защита авторизации
Авторизация использует два слоя:
- Route middleware (
app/app/middleware/auth.global.ts): синхронная проверка сохранённой сессии Supabase в localStorage (hasPersistedSession(),app/app/composables/useAuthSession.ts), без сетевых запросов, работает офлайн. Перенаправляет на/login(сохраняя?redirect=) при отсутствии; перенаправляет/login→/dashboardпри наличии сессии. Демо-режим обходит эту проверку. При возврате после Google OAuth сессия ещё не сохранена, поэтому/login?...&code=остаётся без редиректа, пока код обменивается на месте. - Плагин PowerSync (
app/app/plugins/powersync.client.ts): следит за Supabaseuidи подключает/отключает 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 |
| Плагин PowerSync | app/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 db | app/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 drag | app/app/components/bottomSheet/useBottomSheetDrag.ts |
| Миграции Supabase | app/supabase/migrations/ |
| Правила синхронизации PowerSync | app/powersync/config/sync-config.yaml |
Следующие шаги
- Синхронизация - как PowerSync синхронизирует данные между сервером и локальной SQLite
- Офлайн-first - офлайн-записи, очередь загрузки и обработка ошибок
- Производительность - бюджет холодного старта, ленивые чанки и инструментирование