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

Офлайн-first

Как PowerSync обеспечивает офлайн-записи, очередь загрузки и вывод ошибок.

Финапка работает в режиме офлайн-first через PowerSync. Локальная SQLite является единственным источником истины. Все операции чтения берутся из SQLite; все записи сначала идут в SQLite и ставятся в очередь для загрузки в Supabase Postgres.

Как это работает

действие пользователя (создание/обновление/удаление)
  → оптимистично: items.value обновляется в хранилище немедленно
  → upsertRow / deleteRow записывает в локальную SQLite (синхронно)
    → успех: watchTable срабатывает → хранилище обновляется из SQLite
    → ошибка: восстанавливается prev-снимок + showErrorToast (ошибка локальной записи)
  → PowerSync ставит операцию в очередь на загрузку
    → онлайн: коннектор опустошает очередь → upsert/update/delete в Supabase
    → офлайн: очередь сохраняется в SQLite; опустошается при переподключении

Ручного управления очередью нет, правил свёртки нет, системы frontend-local ID нет. Очередью загрузки управляет PowerSync.

Офлайн-записи

При отсутствии соединения:

  • Чтение продолжает работать - все данные уже в локальной SQLite.
  • Записи идут в локальную SQLite немедленно (оптимистично). Пользователь видит изменение сразу.
  • Операция загрузки добавляется во внутреннюю очередь PowerSync.
  • При переподключении PowerSync автоматически опустошает очередь.

Никаких действий пользователя для запуска синхронизации после переподключения не требуется.

Потеря сессии в офлайне

Уход в офлайн не разлогинивает пользователя. Неудачное обновление токена в офлайне считается повторяемым - supabase-js сохраняет персистнутую сессию (localStorage) и не шлёт SIGNED_OUT, поэтому роут-гард (hasPersistedSession()) оставляет пользователя в приложении, а несинхронизированная очередь продолжает наполняться. Два случая обрабатываются так, чтобы офлайн-записи не терялись:

  • Реактивная сессия ещё не зарезолвилась (холодный старт в офлайне). После протухания access-токена свежий getSession() возвращает null, поэтому реактивный uid равен null до переподключения. Записи проставляют userId через resolveWriteUid(uid.value) (app/app/composables/useAuthSession.ts), который откатывается к синхронно-персистнутому uid. Без этого строка получила бы пустой userId, который RLS отклоняет (42501) при загрузке - молча теряя офлайн-запись.
  • Сессия потеряна непроизвольно (токен отозван / истёк на сервере). Всплывает при переподключении как настоящий SIGNED_OUT. Тогда watcher авторизации ставит синхронизацию на паузу (pausePowerSync - db.disconnect(), без очистки), а не стирает, сохраняя локальную SQLite и несинхронизированную очередь. При наличии ожидающих записей показывается toast на повторный вход (sync.errors.sessionLostPending); повторная авторизация тем же пользователем переподключает и опустошает очередь. Только явный выход (useUserStore.signOut()) стирает локальные данные через disconnectPowerSyncdisconnectAndClear.

Обработка ошибок загрузки

SupabaseConnector.uploadData() разделяет фатальные ошибки и повторяемые:

Фатальные ошибки (отбрасываются)

Классы ошибок Postgres 22xxx (исключение данных), 23xxx (нарушение ограничения целостности) и 42501 (RLS - недостаточно прав) считаются фатальными. Неудавшаяся операция и все операции в очереди после неё отбрасываются, чтобы очередь не блокировалась, а затем автоматически согласуются плагином (planDivergence, app/services/powersync/uploadReconcile.ts):

  • Отклонённые INSERT: локальные строки удаляются для сходимости (сервер их так и не принял). Отмена кошелька/категории каскадно удаляет ссылающиеся на них локальные транзакции, поэтому осиротевших строк не остаётся. Toast: sync.errors.uploadReverted.
  • Отклонённые UPDATE/DELETE: на сервере осталась предыдущая версия, а PowerSync не может повторно вытянуть отдельную строку, поэтому пользователю предлагается деструктивная полная повторная синхронизация (forceResync: очистка локальной SQLite + повторная загрузка серверной истины). Toast: sync.errors.uploadDiverged.

Повторяемые ошибки

Сетевые ошибки, таймауты и все остальные не-фатальные ошибки вызывают пробрасывание ошибки. PowerSync перехватывает его и повторяет попытку с backoff при восстановлении соединения.

Каскадные удаления

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

Сервер не накладывает ограничений внешних ключей (порядок загрузки PowerSync не гарантирован), поэтому ссылочная целостность обеспечивается логикой приложения.

Оптимистичный откат

Каждая запись в хранилище сохраняет снимок prev = items.value до мутации. Если локальная запись в SQLite выбрасывает исключение, восстанавливается items.value = prev и показывается toast. Это покрывает ошибки локальной записи (например, ограничение SQLite, нехватка места). Ошибки отказа на сервере (RLS, ограничение) обрабатываются отдельно обработчиком ошибок загрузки.

Без ручной очереди

Очередью загрузки нативно управляет PowerSync - нет написанной вручную очереди, нет шага replay, нет слияния операций, нет переназначения клиентских ID.

Клиентские ID - это обычные UUID (генерируются на клиенте через crypto.randomUUID() или аналог). Они стабильны и служат постоянным первичным ключом и на устройстве, и на сервере, поэтому ничего не нужно переназначать после загрузки.

Ключевые файлы

ФайлРоль
app/services/powersync/connector.tsSupabaseConnector.uploadData() - опустошение очереди, разделение фатальных/повторяемых ошибок
app/services/powersync/mutations.tsupsertRow, deleteRow - запись в локальную SQLite
app/services/powersync/db.tswatchTable - подписка на изменения SQLite; pausePowerSync - остановить синк, сохранив локальные данные и очередь
app/app/composables/useAuthSession.tsresolveWriteUid - откат на персистнутый uid при записи строк; роут-гард hasPersistedSession
app/app/plugins/powersync.client.tsРегистрирует обработчик ошибок загрузки (toast); ставит синк на паузу (сохраняя данные) при непроизвольной потере сессии
app/app/components/trns/useTrnsStore.tsОптимистичная запись + откат, каскадное удаление trns
app/app/components/wallets/useWalletsStore.tsОптимистичная запись + откат + каскадное удаление trns
app/app/components/categories/useCategoriesStore.tsОптимистичная запись + откат + каскадное удаление trns
app/app/composables/useStoreSync.tsshowErrorToast, showSuccessToast, demo-only createDebouncedPersist

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

  • Синхронизация - потоки синхронизации PowerSync, очередь загрузки и обработка удалений
  • Архитектура - инициализация приложения и паттерн хранилищ