just dev поднимает БД, миграции, сборку web и API на :8000. build-web больше не зависит от app-web-assets (sqlite3.wasm и drift_worker.js закоммичены и пересобираются вручную), в docs/ai/offline-cache.md это отражено.
106 lines
8.4 KiB
Markdown
106 lines
8.4 KiB
Markdown
# Offline-кэш (фаза 5)
|
||
|
||
Контракт, по которому каждый экран Flutter-клиента получает поддержку офлайна. Реализован
|
||
целиком на `core/cache/*` + один Dio-интерцептор — экрану достаточно поменять тип провайдера
|
||
и один раз показать баннер, ничего не меняя в том, как он делает запросы.
|
||
|
||
## Идея
|
||
|
||
Кэш живёт не в провайдерах и не в экранах, а в `Dio` — на уровне HTTP-транспорта, ниже
|
||
сгенерированного `fintracker_api`-клиента:
|
||
|
||
- **`CacheInterceptor`** (`core/cache/cache_interceptor.dart`) добавлен в `apiProvider`
|
||
(`core/api/api_client.dart`) последним, после `DateQueryInterceptor` и `_AuthInterceptor`.
|
||
- `onResponse`: успешный `GET` (статус 200) пишется в кэш по ключу `путь?отсортированные_параметры`
|
||
(`CacheInterceptor.requestKey`) — fire-and-forget, ответ не ждёт записи.
|
||
- `onError`: если запрос — `GET` и ошибка сетевая (`connectionError`, `connectionTimeout`,
|
||
`receiveTimeout`, `sendTimeout`), интерцептор ищет последний закэшированный ответ по тому же
|
||
ключу и, если он есть, подменяет ошибку успешным `Response` с `extra['fetchedAt']` —
|
||
временем, когда этот ответ был реально получен от сервера. Если в кэше пусто — ошибка идёт
|
||
дальше как обычно.
|
||
- Мутации (не-`GET`) никогда не кэшируются и никогда не подменяются: упавший `POST/PATCH/DELETE`
|
||
обязан остаться ошибкой.
|
||
- Реальная ошибка сервера (4xx/5xx, `DioExceptionType.badResponse`) кэшем не перекрывается —
|
||
подменяется только сетевая недоступность.
|
||
- **`ResponseCacheDatabase`** (`core/cache/response_cache.dart`) — таблица `drift` с одной строкой
|
||
на ключ (`requestKey`, `body` как JSON-строка, `fetchedAt`). `driftDatabase()` из
|
||
`package:drift_flutter` сам выбирает бэкенд: нативный sqlite (FFI) на Android/iOS/desktop,
|
||
wasm+OPFS (с падением на IndexedDB) в браузере.
|
||
- Сгенерированный API-клиент (`app/packages/api_client`) копирует `Response.extra` в возвращаемый
|
||
`Response<T>` без изменений (проверено в `networth_api.dart` и остальных) — значит,
|
||
`extra['fetchedAt']` долетает до провайдера без единой правки в генерируемом коде.
|
||
|
||
## Контракт для экрана
|
||
|
||
1. **Провайдер оборачивает данные в `Cached<T>`** (`core/cache/cached.dart`) вместо голого `T`:
|
||
|
||
```dart
|
||
final xProvider = FutureProvider.autoDispose<Cached<X>>((ref) async {
|
||
final r = await ref.watch(apiProvider).getXApi().x();
|
||
return r.cached; // extension: Cached(r.data as T, fetchedAt: r.extra['fetchedAt'])
|
||
});
|
||
```
|
||
|
||
Для эндпоинтов, которые сами обрабатывают `null`/404 (см. `metricsStatusProvider`,
|
||
`portfolioSummaryHomeProvider` в `features/home/providers.dart`), оборачивайте вручную:
|
||
`Cached(value, fetchedAt: r.extra['fetchedAt'] as DateTime?)`.
|
||
|
||
2. **Экран разворачивает `.data`** там, где раньше было голое значение
|
||
(`data: (cached) => Widget(x: cached.data)`), и считает баннер один раз на весь экран:
|
||
|
||
```dart
|
||
final stale = oldestFetch([a.valueOrNull?.fetchedAt, b.valueOrNull?.fetchedAt, ...]);
|
||
// ...
|
||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||
```
|
||
|
||
`oldestFetch` берёт самое старое время среди тех провайдеров, что реально пришли из кэша
|
||
(`fetchedAt != null`); один баннер на экран — не один на плитку, иначе на дашборде из 8
|
||
виджетов это станет шумом. `StaleBanner` (`core/widgets/stale_banner.dart`) — тонкий
|
||
контейнер в цветах темы («Нет соединения — данные на 12.09.2026 10:31»), без логики.
|
||
|
||
3. **В тестах экрана — оверрайдить КАЖДЫЙ провайдер**, который экран смотрит, значением
|
||
`Cached(...)`. Если забыть один (как было с `portfolioSummaryHomeProvider` до этой фазы),
|
||
тест провалится не сразу, а тихо создаст настоящий `apiProvider` → настоящий
|
||
`ResponseCacheDatabase` — drift выведет предупреждение "multiple databases" и тест будет
|
||
трогать реальный sqlite-файл вместо теста в изоляции. Смотрите `test/home_page_test.dart`
|
||
и `_overrides()` в нём как образец.
|
||
|
||
Эталонная реализация — весь `features/home/` (провайдеры + `HomePage`). Остальные ~24 экрана
|
||
на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от
|
||
друга (можно параллельно, в отдельных worktree).
|
||
|
||
## Жизненный цикл
|
||
|
||
Кэш — это вся финансовая картина в открытом sqlite/IndexedDB, поэтому он живёт не дольше сеанса:
|
||
`AuthController` чистит его вместе с refresh-токеном при `logout()` и когда сервер ответил 401 на
|
||
refresh. Смена адреса API (`switchApiBaseUrl`) идёт через `logout()`, так что данные старого
|
||
сервера не показываются новому. Ошибка очистки не блокирует выход. TTL и лимита размера у кэша
|
||
по-прежнему нет.
|
||
|
||
## Адрес API
|
||
|
||
`apiBaseUrlProvider`: сохранённый в `shared_preferences` адрес (читается в `main()` до `runApp`),
|
||
иначе `--dart-define=API_BASE_URL`, иначе origin страницы (web) / `http://127.0.0.1:8000`.
|
||
Меняется в Настройках и на экране входа.
|
||
|
||
## Веб: sqlite3.wasm + drift_worker.js
|
||
|
||
Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как
|
||
`openapi/openapi.json` и Dart-клиент). Пересобирать `just app-web-assets`, когда обновляются
|
||
версии пакетов `drift`/`sqlite3` — recipe качает `sqlite3.wasm` с GitHub-релиза пакета
|
||
`sqlite3.dart`, версия релиза берётся из `flutter pub deps --json`, и компилирует
|
||
`drift_worker.js` из исходника `package:drift/web/drift_worker.dart` через `dart compile js`.
|
||
`just build-web` этот рецепт не вызывает — запускать вручную.
|
||
|
||
## Что не проверено
|
||
|
||
`flutter build linux --debug` на сборке нативного sqlite3 не дошёл до проверки — упал раньше,
|
||
на несвязанной с кэшем проблеме `flutter_secure_storage_linux` (старый `nlohmann/json.hpp`,
|
||
`-Werror=deprecated-literal-operator` на новых компиляторах; плюс сначала не хватало
|
||
`libsecret`/`pkg-config` в `flake.nix` — это уже добавлено). Сам `sqlite3`/drift на Dart VM
|
||
(native FFI) отработал корректно — это видно по `flutter test`, где `ResponseCacheDatabase`
|
||
реально открывает файл через нативный бэкенд без ошибок. Кто будет делать пункт «Linux/Windows
|
||
в CI» — начните с починки `flutter_secure_storage_linux` (обновить пакет или добавить
|
||
`-Wno-deprecated-literal-operator` в его CMake), это отдельная от кэша проблема.
|