Тех. решения
Осознанные технические решения и их обоснование.
PowerSync offline-first (локальная SQLite как источник правды)
Приложение использует PowerSync для репликации Supabase Postgres в локальную WASM SQLite базу данных на клиенте. Сторы читают данные исключительно из этой локальной SQLite через watchTable('SELECT * FROM ...', [], cb).
Почему локальная SQLite как источник правды:
Это делает приложение полностью функциональным без подключения к сети. Чтение мгновенно (без round-trip). Одна подписка watchTable срабатывает сразу с текущими локальными строками И при каждом изменении - будь то локальная запись или входящая серверная синхронизация. Этот единственный механизм заменяет прежнее разделение между начальной загрузкой и realtime-подпиской.
Почему PowerSync вместо кастомной офлайн-очереди:
В прошлом использовалась написанная вручную офлайн-очередь (components/offline/replay*) с XOR-хеш обнаружением удалений, ремапингом локальных ID и пагинированным дельта-запросом. PowerSync делает всё это автоматически: поддерживает локальную CRUD-очередь, сливает её в Supabase через uploadData коннектора, согласует серверные изменения через logical replication. Никакого кода очереди, никакой логики несовпадения хешей, никакого ремапинга ID.
Почему IDBBatchAtomicVFS (бэкенд IndexedDB) вместо OPFS:
OPFS (Origin Private File System) быстрее, но требует SharedArrayBuffer, который требует заголовков cross-origin isolation (COOP/COEP). Эти заголовки ломают Google Fonts, Iconify CDN и другие сторонние ресурсы. IDBBatchAtomicVFS (на основе IndexedDB) не требует cross-origin isolation и работает в стандартном деплое.
Оптимистичные записи с откатом
Методы сторов немедленно обновляют items (оптимистично), затем вызывают upsertRow/deleteRow для записи в локальную SQLite. Если локальная запись падает, оптимистичное изменение откатывается путём восстановления снимка prev, и показывается тост с ошибкой.
Почему оптимистично, а не с ожиданием: Ожидание записи в SQLite блокировало бы UI, даже несмотря на то что это локальная внутрипроцессная операция. Оптимистичные обновления дают мгновенную обратную связь. Путь отката обрабатывает редкий случай, когда сама локальная запись завершается ошибкой (например, конфликт транзакции).
Замечание об отклонении сервером: серверные ошибки (нарушения RLS Postgres, исключения данных) выводятся отдельно - через обработчик upload-error в uploadData коннектора, а не через catch локальной записи.
shallowRef для элементов сторов
Все Pinia-сторы используют shallowRef<Map | null> вместо ref для коллекции элементов. Методы сторов создают новые ссылки на объекты при каждой мутации для запуска реактивности.
Почему:ref глубоко отслеживал бы каждое вложенное свойство потенциально тысяч объектов транзакций/кошельков/категорий. shallowRef отслеживает только ссылку верхнего уровня. Явный паттерн «создать новый объект» при записи - осознанный компромисс: чуть более многословные сторы, значительно меньшие накладные расходы реактивности.
Клиентские UUID (без ремапинга)
Каждая новая сущность получает UUID, сгенерированный на клиенте (crypto.randomUUID() или аналог) до локальной записи. Этот UUID является постоянным ID - клиент владеет им весь офлайн-цикл, и он никогда не переназначается.
Почему:
PowerSync ставит запись в очередь локально и загружает её позже, поэтому ID должен быть стабильным ещё до того, как строку увидит сервер. Схема Supabase использует text PK с default gen_random_uuid()::text, поэтому любой UUID, сгенерированный клиентом, принимается как есть. Нет обращения к серверу за выдачей ID, нет ремапинга, нет каскадных обновлений строк, ссылающихся на новую сущность.
Почему нет FK-ограничений в Postgres: Порядок загрузки PowerSync не гарантирован. Транзакция, ссылающаяся на кошелёк, может быть загружена раньше самого кошелька. FK-ограничения отклонили бы валидные данные. Ссылочная целостность обеспечивается логикой приложения.
SQLite views требуют INSERT/UPDATE, а не upsert
Клиентские таблицы PowerSync - это SQLite views с INSTEAD OF триггерами. Они принимают обычные INSERT и UPDATE, но НЕ поддерживают INSERT ... ON CONFLICT (синтаксис upsert).
Как upsertRow это решает:
Выполняет проверку существования, затем либо INSERT (новая строка), либо UPDATE (существующая), обёрнутые в writeTransaction для атомарности. Вызывающий всегда владеет ID, поэтому INSERT/UPDATE однозначны.
Троттлинг reconcileTrns и подавление изменений
useTrnsStore использует 120ms окно троттлинга для своего watchTable (против 30ms по умолчанию для других таблиц). Также используется reconcileTrns для подавления «эха» собственной оптимистичной записи.
Почему большее окно троттлинга: Транзакции - самая большая таблица. Во время начальной синхронизации PowerSync генерирует много быстрых событий изменения строк. 120ms trailing coalesce сворачивает всплеск в один перезапрос, снижая нагрузку на CPU.
Почему reconcileTrns:
После оптимистичной записи watch снова срабатывает с теми же данными (эхо записи). reconcileTrns обнаруживает, что ничего не изменилось (сравнивая по updatedAt), и возвращает тот же prev ref без изменений, подавляя лишний цикл реактивности. Также переиспользует неизменённые объекты строк, так что rowToTrn выполняется только для дельты.
Очистка перед подключением другого пользователя (fail-closed)
connectPowerSync проверяет хранящийся finapp.psDbOwnerUid (localStorage). Если локальная SQLite принадлежит другому пользователю, вызывается disconnectAndClear() перед подключением - сначала очищается локальная база данных.
Почему fail-closed: Если сама очистка завершается ошибкой, маркер владельца сохраняется. Следующая сессия другого пользователя всё равно попытается очиститься перед чтением данных. Это предотвращает утечку данных пользователя A к пользователю B на общем устройстве.
Жёсткая навигация при выходе
Выход из системы использует window.location.href = '/login' вместо навигации Vue Router.
Почему:
Жёсткая навигация уничтожает всё JavaScript-состояние - Pinia-сторы, WebSocket-соединения, реактивные наблюдатели, кешированные данные. Это предотвращает утечку устаревшего состояния авторизации в следующую сессию. navigateTo() Vue Router сохранил бы всё состояние в памяти.
Безопасный арифметический парсер в калькуляторе
Калькулятор формы транзакции использует рекурсивный нисходящий парсер (app/components/trnForm/utils/calculate.ts) вместо eval() или new Function().
Почему:new Function(expression) - вектор инъекции кода: вредоносная строка может выполнить произвольный JavaScript. Также требует unsafe-eval в Content-Security-Policy. Кастомный парсер поддерживает только +, -, *, / над числовыми литералами, отклоняя всё остальное.
Каскадное удаление в сторах
Postgres не имеет FK-ограничений (см. выше). При вызове deleteWallet/deleteCategory стор также удаляет транзакции сущности из локальной SQLite (иначе подписка watch снова добавила бы их на следующем такте). При откате восстанавливаются и сущность, и её транзакции.
Почему не полагаться на серверный каскад: Серверного каскада нет (нет FK-ограничений). Клиент должен очищать локально, чтобы локальное SQLite-представление оставалось консистентным с предполагаемым состоянием.
Вычисляемые дочерние категории вместо денормализованных childIds
Категории хранят только parentId. Дочерние ID вычисляются динамически фильтрацией категорий по parentId === id.
Почему:
Ранее родительские категории хранили массив childIds. Это требовало синхронизации обеих сторон при каждом перемещении, создании или удалении - источник багов. Вычисление детей из parentId - единый источник правды без проблем синхронизации. Количество категорий достаточно мало (десятки-сотни), чтобы стоимость поиска была ничтожной.
Ограничение вложенности категорий до 2 уровней
Категории поддерживают только родитель - дочерний, без более глубокой вложенности.
Почему: Более глубокая вложенность усложняет UI (разбивка статистики, выбор категорий, хлебные крошки) без практической пользы для учёта личных финансов. Два уровня покрывают 99% случаев использования (например, Еда > Продукты).
Приоритет инъекции темы
Стили темы используют tagPriority: -2 в useHead(), а скрипты плагинов используют tagPriority: -1.
Почему:
Это гарантирует применение CSS-переменных темы до первой отрисовки. Цепочка приоритетов: useHead стили (-2) - скрипты плагинов (-1) - @layer theme (всегда проигрывает non-layered). Non-layered CSS в theme.css переопределил бы useHead() порядком в документе, поэтому --ui-radius и --ui-primary не должны устанавливаться там.
Следующие шаги
- Дата-утилиты - типобезопасная работа с периодами, интервалы, форматирование
- Стратегия валидации - клиентская валидация с Zod
- Архитектура - как решения влияют на структуру проекта