docs: explain persisted T-Bank reads and synchronization
Conventional commits / commits (pull_request) Successful in 14s
Docs security / security (pull_request) Successful in 8s
Docs verify / verify (pull_request) Successful in 13s
Docs security / security (push) Successful in 8s
Docs verify / verify (push) Successful in 12s

Document local data ownership, coverage and freshness, refresh behavior, failure
recovery and additive migration rollback. Record ADR 017.
This commit was merged in pull request #22.
This commit is contained in:
2026-09-26 21:22:50 +03:00
parent 55610ea2b4
commit 07de062ebb
19 changed files with 459 additions and 432 deletions
+29 -45
View File
@@ -51,9 +51,9 @@ Backend публикует статические маршруты под `/api/
используют прежнюю карточку. Доля относится к позициям той же валюты
во всём показанном портфеле, без денежных остатков.
- Журнал объединяет операции по убыванию времени, при равном времени использует
порядок идентификаторов счетов, сохраняя порядок внутри одного счёта. ID
порядок идентификаторов счетов и операций. ID
операций разных счетов не считаются дублями. Переводы не схлопываются.
- Доходы собираются из всех страниц операций каждого счёта за общие периоды.
- Доходы рассчитываются из согласованной локальной выборки операций за общие периоды.
Итоги, сравнения, временные интервалы и принадлежность выплат рассчитывает
сервис дохода.
Для сравнения с годом ранее используется текущий состав счетов; новый счёт
@@ -65,55 +65,39 @@ Backend публикует статические маршруты под `/api/
## Полнота, свежесть и доступ
Для каждого общего запроса backend определяет состав доступных счетов
и единое поколение подключения. Получение данных использует эту же границу подключения;
проверки сессии, доступа и существующий лимитер внешних запросов сохраняются.
Финансовые данные не сохраняются в БД; снимки и продолжения хранятся
в памяти процесса.
Общий запрос читает membership и разрешённые snapshots из SQLite в одной
согласованной границе. Время источников может различаться: это не обещание
одновременного наблюдения счетов у брокера. Неполный счёт делает aggregate
неполным; отсутствующие суммы не подменяются нулями. `lastSuccessfulAt`
показывает самое раннее время обязательных источников.
Ошибка источника не превращается в частичный успешный ответ раздела.
Портфель может использовать разрешённый существующей политикой запасной снимок
отдельного счёта. Тогда общий `isStale` равен `true`, а `lastSuccessfulAt`
показывает самое раннее время среди включённых снимков. Для пустого состава
возвращается корректный пустой результат. Общий запрос не является атомарным
наблюдением всех счетов в один момент времени.
Ошибка блока операций, доходов или будущих событий не скрывает основной
портфель. При фоновом обновлении страницы уже полученный результат сохраняется
с индикатором или предупреждением и повторной попыткой. Смена сессии или
подключения сбрасывает общий режим вместе с остальными финансовыми запросами.
Секции портфеля, операций, дохода и событий готовы независимо. Ошибка
обновления сохраняет прежний полный результат того же scope и периода.
Известный отказ доступа и смена подключения очищают personal view.
## Общая пагинация операций
Сервис объединяет потоки страниц каждого счёта и сохраняет непрочитанные строки
между запросами. Фильтрованные пустые страницы не обрывают поток. Повреждённая
цепочка курсоров и нарушенный порядок дат возвращают ошибку без частичной страницы.
Страница строится из локальных строк с порядком occurredAt, accountId и ID
операции. Cursor проверяет пользователя, подключение, membershipRevision,
фильтры, limit и ревизии операций. Изменение rowset возвращает
`400 INVALID_OPERATIONS_CURSOR`; клиент сбрасывает стек страниц, сохраняя
применённые фильтры. Coverage/status без изменения строк не ломают cursor.
Продолжение не зависит от RAM-кэша или перезапуска.
Курсор общего журнала — случайный непрозрачный идентификатор продолжения,
привязанного к пользователю, поколению подключения, составу счетов, фильтрам
и размеру страницы. Верхняя граница периода фиксируется первым запросом.
Состояние ограничено 100 продолжениями на процесс со сроком жизни пять минут;
повторный запрос того же продолжения возвращает тот же успешный результат.
Неизвестный, вытесненный, истёкший либо несовместимый курсор возвращает
`400 INVALID_OPERATIONS_CURSOR`. Действие «Повторить» сбрасывает пагинацию,
обновляет первую страницу и сохраняет фильтры. Перезапуск backend также
инвалидирует продолжения. Раздел рассчитан на один экземпляр backend, как
существующий финансовый кэш.
Повторная сверка полного показанного периода публикует все счета и интервалы
одной транзакцией; успешное первое окно не смешивается с прежними остальными.
Подробнее — [журнал](../operations/overview.md#получение-и-полнота).
## Совместимость и границы требований
Общий режим добавляет маршруты, сохраняя маршруты отдельных счетов.
При совместном обновлении сначала нужен backend, затем frontend с
соответствующим сгенерированным контрактом; прежний клиент продолжает
работать с отдельными счетами. При откате сначала возвращается клиент.
Этот механизм не требует изменения схемы SQLite и миграции финансовых данных.
Общий режим использует те же persistent sources и envelope, что и отдельный
счёт. Новый envelope несовместим с прежним bare response: backend и frontend
поставляются согласованной парой по [HTTP contract](../../architecture/http-api.md).
Additive migration сохраняет прежние таблицы; rollback приложения не удаляет
новые данные и не заменяет рабочую базу старым backup. Возврат требует заранее
проверенной совместимой пары; произвольный прежний клиент не считается совместимым.
Процедура backup/restore описана в [runbook](../../architecture/deployment.md).
Основные спецификации [портфеля](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/portfolio-view/spec.md),
[операций](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/brokerage-account-ledger/spec.md),
[дохода](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/brokerage-account-income-analytics/spec.md)
и [событий](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/brokerage-account-upcoming-events/spec.md)
описывают отдельный счёт. Общий режим подтверждён действующими маршрутизаторами,
сервисами и [проверками агрегации](https://git.personal.ksv741.ru/WealthLedger/backend/src/commit/016782657acfd35ff3fd755462007938fac35156/src/modules/investments/all-accounts.spec.ts),
но не выделен в самостоятельную основную спецификацию. Не следует считать
описание общего режима изменением требований к отдельному счёту.
Основные спецификации областей сохраняют исходные понятия отдельного счёта.
Утверждённый change `persist-tbank-data` распространяет полноту, локальный
доступ, атомарную сверку и состояния синхронизации на режим `all`.
+3 -3
View File
@@ -32,7 +32,7 @@
| Cash Balance | Суммы из `GetPositions.money`, объединённые по валюте | Заблокированные деньги не прибавляются; торгуемый инструмент `CURRENCY` остаётся позицией |
| `totalsByCurrency` | Стоимости позиций + доступные деньги той же валюты | Не включает невычислимую стоимость; не обещает полную оценку портфеля |
| `lastSuccessfulAt` | Время формирования успешного снимка, UTC | Не является временем каждой котировки; запасной снимок сохраняет исходное время |
| `isStale` | Backend вернул предыдущий полный снимок после временной ошибки | Не описывает состояние клиентского кэша или ошибку фонового запроса |
| `isStale` | Истёк минутный срок свежести сохранённого portfolio source | Не описывает состояние клиентского кэша или ошибку фонового запроса |
Ненулевые денежные остатки одной валюты суммируются. Позиции одинаковых
инструментов в ответе отдельного счёта не объединяются; визуальная группировка
@@ -56,8 +56,8 @@ UID определяет конкретный инструмент брокер
ISIN, тикер и `logoName` могут быть `null`. `logoName` — имя файла бренда,
а не URL: frontend строит ссылку на публичный CDN. Ошибка изображения
или метаданных не меняет количество, суммы и свежесть снимка и не скрывает
финансовые данные. Имя ожидается перед завершением нового снимка;
ошибка поиска оставляет доступный идентификатор.
финансовые данные. Metadata обновляется независимо; отсутствие имени не задерживает выдачу
финансового снимка и оставляет доступный идентификатор.
## Структура и доли
+33 -145
View File
@@ -1,158 +1,46 @@
# Загрузка портфеля
Поток начинается при открытии `/accounts/:accountId` пользователем
с действующей сессией. Его результат — полный нормализованный снимок
доступного счёта либо объяснимая ошибка. Браузер обращается к backend;
доступ к брокерскому токену и внешние запросы остаются на сервере.
Экран `/accounts/:accountId` читает последний полный snapshot из SQLite
после проверки сессии, подключения и локального membership. Если список
счетов ещё неизвестен, сервер сообщает о загрузке доступа без выдачи личных
данных неизвестного ID. Известный недоступный счёт возвращает `404`.
## Основной обмен
Сначала frontend проверяет свой успешный кэш: в течение 30 секунд повторное
открытие может показать ответ без нового HTTP-запроса. Следующая схема
описывает запрос, который действительно дошёл до backend.
Обычный GET возвращает локальный envelope. Missing/stale зависимости
запрашиваются в фоне; чтение не ждёт T-Bank. Для нового полного snapshot
нужны оба финансовых ответа `GetPortfolio` и `GetPositions`. Суммы и позиции
публикуются вместе; metadata дополняет представление независимо, с доступным
UID/FIGI вместо имени до её загрузки.
```mermaid
sequenceDiagram
participant F as Frontend
participant B as Backend API
participant D as SQLite
participant T as T-Bank
F->>B: Запрос портфеля счёта с Bearer token
B->>B: Проверяет сессию
B->>D: Читает текущее подключение пользователя
D-->>B: Подключение либо отсутствие
alt Подключения нет
B-->>F: 409 TBANK_NOT_CONNECTED
else Подключение доступно
B->>B: Определяет поколение подключения
alt Снимок того же поколения не старше 30 секунд
B-->>F: Полный снимок, isStale=false
else Нужен новый снимок
B->>T: Получает открытые счета
T-->>B: Состав доступных счетов
alt Счёт недоступен
B-->>F: 404
else Счёт доступен
par Финансовые позиции
B->>T: GetPortfolio
T-->>B: Позиции и цены
and Доступные деньги
B->>T: GetPositions
T-->>B: Денежные остатки
end
B->>T: Метаданные отсутствующих инструментов по UID, затем FIGI
T-->>B: Метаданные, отсутствие или ошибка
B->>B: Нормализует состав и точно вычисляет суммы
B-->>F: Полный новый снимок, isStale=false
end
end
end
```
Свежесть portfolio — 60 секунд. Истечение срока не удаляет snapshot и не
создаёт таймер. «Обновить» на обзоре перечитывает зависимости текущего экрана:
membership, portfolio, показанные операции, доход и будущие события.
Секции могут быть готовы независимо. Подробнее —
[свежесть](../../../architecture/cross-cutting/data-freshness.md).
Свежий серверный снимок выдаётся после разрешения текущего сохранённого
подключения, но без нового `GetAccounts`. После истечения свежего кэша
проверка доступности счёта предшествует финансовым RPC. SQLite хранит
подключение, а не снимок или право владения счётом вне этого подключения.
## Границы доступа
Для нового снимка требуются оба финансовых ответа. Backend не выдаёт
позиции без денег или деньги без позиций как полный портфель.
Метаданные дополняют имя и идентификаторы, но их временная недоступность
не срывает финансовый результат. Поиск выполняется партиями по пять позиций,
чтобы крупный портфель не переполнял очередь одновременными запросами.
Порядок позиций сохраняется. Backend ищет сведения по UID, затем по FIGI,
если UID не дал пригодного названия или тикера.
Ожидание метаданных ограничено 30 секундами на снимок счёта. После этого
используется доступный кэш либо идентификатор, новые поиски для снимка
не запускаются. Уже начатые запросы могут закончиться позже и обновить общий
справочный кэш, но не меняют выданный финансовый снимок.
Общий серверный клиент обеспечивает TLS, очередь,
ограничение частоты, deadline RPC и разрешённые повторные попытки —
[граница интеграции](../../../integrations/tbank.md).
## Время и изменение подключения
| Слой | Что сохраняется | Условие повторного использования |
| --- | --- | --- |
| Frontend | Последний успешный HTTP-ответ для выбранного счёта и сессии | Свежим считается 30 секунд; при обычном обновлении уже показанный ответ остаётся видимым |
| Backend | Полный снимок пары пользователь–счёт в памяти процесса | До 30 секунд, только в том же поколении текущего подключения |
| Backend при временной ошибке | Тот же полный снимок | Не старше пяти минут с момента успешного сохранения, только в том же поколении |
| Метаданные инструмента | Успешный поиск или подтверждённое отсутствие | Свежими считаются 24 часа; общий кэш на 500 ключей UID/FIGI, ошибки не сохраняются |
Это параметры текущей реализации, а не самостоятельная политика этого
документа: [снимки](https://git.personal.ksv741.ru/WealthLedger/backend/src/commit/016782657acfd35ff3fd755462007938fac35156/src/modules/investments/portfolio/application/portfolio.service.ts),
[метаданные](https://git.personal.ksv741.ru/WealthLedger/backend/src/commit/016782657acfd35ff3fd755462007938fac35156/src/integrations/tbank/instrument-metadata.service.ts) и
[клиентский запрос](https://git.personal.ksv741.ru/WealthLedger/frontend/src/commit/9f7e342333b80bfacfd5bc394a2dcd0613d33ff3/src/entities/portfolio/api/usePortfolio.ts).
Пять минут — предельный возраст успешного снимка, а не дополнительные
пять минут после окончания свежих 30 секунд. Запасной ответ сохраняет
исходное `lastSuccessfulAt` и получает `isStale: true`.
После истечения 24 часов backend обновляет справочные данные. Если обновление
временно недоступно, ранее успешные сведения остаются запасным вариантом,
пока запись есть в ограниченном кэше процесса. Ошибка не продлевает срок
свежести: следующий поиск снова пытается обновить запись. Это относится только
к названию, тикеру, ISIN и логотипу, а не к ценам или денежным значениям.
Перезапуск backend и вытеснение записи из кэша удаляют этот запасной вариант.
Неполный ответ без названия и тикера также не затирает известные сведения
и не обновляет срок их свежести. Подтверждённое отсутствие инструмента
заменяет прежнюю запись отрицательным результатом поиска.
Время снимка фиксируется при получении финансовых ответов, до ожидания
названий. Сроки серверного кэша отсчитываются от этого времени, поэтому
долгая загрузка справочных данных не делает старые суммы новыми.
Успешные справочные данные общие для всех пользователей; выполняющиеся
запросы объединяются только внутри одного брокерского токена, чтобы ошибка
его авторизации не передавалась другому подключению.
Успешное создание, замена или удаление подключения меняет границу
повторного использования финансовых данных. Старое или поздно завершившееся
получение снимка не делает его доступным новым запросам другого поколения.
Явная очистка памяти при изменении подключения дополняет эту проверку.
Frontend сбрасывает всё поддерево финансовых запросов при изменении
подключения и общий кэш сессии при выходе; старый портфель не служит
заглушкой загрузки нового подключения.
В общем режиме сначала определяется состав доступных счетов одного
подключения, затем получаются их снимки. Это
[не атомарное наблюдение нескольких счетов](../all-accounts.md#полнота-свежесть-и-доступ).
Публикация проверяет connection revision и attempt в той же транзакции.
Замена или удаление подключения не допускает позднюю запись прежней работы.
Frontend очищает недоступный personal view и отклоняет запоздалые ответы.
В режиме [«Все счета»](../all-accounts.md#полнота-свежесть-и-доступ) состав
accounts и snapshots читается согласованно, но их source times могут различаться.
## Ошибки и результат для пользователя
| Причина | Ответ backend | Действие на экране |
| --- | --- | --- |
| Нет подключения | `409 TBANK_NOT_CONNECTED` | Открыть настройки T-Bank |
| Брокер отклонил токен | `400 INVALID_TBANK_TOKEN` | Открыть настройки; токен автоматически не удаляется |
| Счёт недоступен через текущее подключение | `404` | Вернуться к списку счетов |
| Временная ошибка, пригодный предыдущий снимок есть | `200`, `isStale: true` | Показать снимок и его исходное время с предупреждением |
| Временная ошибка, пригодного снимка нет | `503 TBANK_UNAVAILABLE` | Повторить загрузку |
| Неизвестная ошибка | Безопасная ошибка API | Повторить загрузку |
Нет подключения — `409 TBANK_NOT_CONNECTED`, известный отказ токена —
`400 INVALID_TBANK_TOKEN`, недоступный счёт — `404`. Они не маскируются
запасным snapshot. Transient source failure оставляет прежний полный портфель
и его исходное время с безопасным предупреждением. При отсутствии snapshot
показывается missing/failed, без нулевых итогов. `503` означает ошибку
обслуживания HTTP-запроса, а не принятой фоновой работы.
`INVALID_TBANK_TOKEN` и `404` не заменяются серверным запасным снимком.
Временная ошибка определения доступных счетов также может использовать
пригодный снимок того же поколения. При временном сбое метаданных сначала
используются ранее успешные сведения, а при их отсутствии — идентификатор
инструмента; это не устанавливает `isStale`. Отказ авторизации при поиске
метаданных не маскируется запасным названием.
Observer запрашивает статус известной работы, затем `sync=observe` читает
изменившийся ресурс без запуска синхронизации. При уходе и terminal outcome
наблюдение прекращается. Повторное открытие может запросить недостающее;
после рестарта сохранённый snapshot доступен сразу.
Для запроса самого портфеля на клиенте нет polling, автоматического повтора или
обновления при возврате фокуса. При обновлении того же уже показанного
результата клиент оставляет его на экране, добавляя индикатор загрузки
или предупреждение с доступной с клавиатуры повторной попыткой.
Это клиентский сохранённый ответ, независимо от серверного `isStale`.
Frontend отображает название и остальные сведения из ответа портфеля.
Повторные попытки поиска для акций, облигаций и других типов инструментов
принадлежат [общему клиенту T-Bank на backend](../../../integrations/tbank.md#конфигурация-и-ограничения).
Первое обращение к крупному портфелю может потребовать много метаданных.
Переполнение общей очереди RPC может произойти при конкурирующих запросах;
поиск метаданных в этом случае повторяется с ограниченными паузами. После
успешного поиска метаданные используются повторно до истечения
кэша. Значение `TBANK_RATE_LIMIT_PER_SECOND` следует соотносить с квотой
источника; его увеличение не является гарантированным исправлением
ограничения самого T-Bank.
Проверяемые правила: [свежесть и запасной снимок](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/portfolio-view/spec.md#requirement-свежесть-и-fallback-полного-portfolio-snapshot),
[клиентские состояния](https://git.personal.ksv741.ru/WealthLedger/specs/src/commit/9e227a563dda67620f0ad78228aa8a4c18bf35a0/openspec/specs/portfolio-view/spec.md#requirement-состояния-и-ошибки-portfolio-на-клиенте).
Для расследования конкретного значения используйте
[порядок диагностики](../how-to/investigate-discrepancy.md).
Денежные правила находятся в [справочнике](../data-reference.md),
проверка расхождения — в [диагностике](../how-to/investigate-discrepancy.md),
RPC/лимиты — в [интеграции T-Bank](../../../integrations/tbank.md).
@@ -17,7 +17,7 @@
каждой котировки, а общий портфель может соединять разные моменты.
Если запрос завершился ошибкой, доступное действие «Повторить» должно
обновить результат или оставить объяснимую ошибку. Перезагрузка backend
удаляет кэш и не восстанавливает историю стоимости.
сохраняет последний полный снимок в SQLite, но не создаёт историю стоимости.
3. **Проверьте состав суммы.** Для одной валюты итог складывается из
вычислимых стоимостей позиций и доступных денег. Не добавляйте деньги
второй раз, не складывайте валюты и не включайте заблокированные средства.
+8 -8
View File
@@ -17,11 +17,11 @@
[справочнике данных](data-reference.md).
Доступность счёта определяется [текущим подключением и списком счетов](../accounts/overview.md).
SQLite не хранит сам портфель: backend получает данные через серверную
[интеграцию T-Bank](../../integrations/tbank.md) и временно сохраняет успешный
снимок в памяти. Frontend получает нормализованный результат через HTTP API.
Backend получает данные через серверную
[интеграцию T-Bank](../../integrations/tbank.md) и постоянно сохраняет последний
полный снимок в SQLite. Frontend получает нормализованный результат через HTTP API.
[Поток загрузки](flows/portfolio-loading.md) объясняет границы доступа,
свежий кэш и восстановление после временной ошибки.
свежесть снимка и восстановление после временной ошибки.
Обзор счёта также показывает пять последних [операций](../operations/overview.md),
[полученный доход](../income/overview.md) текущего месяца и до пяти
@@ -79,7 +79,7 @@ SQLite не хранит сам портфель: backend получает да
не содержат данных и не получают фокус. При обычном фоновом обновлении
сохраняются предыдущие суммы и время успешного получения в UTC.
Серверный `isStale` обозначает разрешённый запасной снимок backend.
Серверный `isStale` обозначает истечение минутной свежести снимка.
Клиентское предупреждение означает неудачную попытку обновить уже показанный
ответ. Эти состояния независимы и могут появиться одновременно.
Успешный повторный запрос обновляет суммы и время, снимает клиентскую ошибку;
@@ -88,9 +88,9 @@ SQLite не хранит сам портфель: backend получает да
В текущем решении нет долговременной истории снимков, графиков стоимости
портфеля, валютной конвертации, учёта виртуальных позиций или заблокированных
средств. Кэш теряется при перезапуске и рассчитан на локальную работу одного
экземпляра backend. Отдельной команды принудительного обновления на экране
нет; повторная попытка доступна после ошибки.
средств. Последний полный снимок сохраняется после перезапуска; выполнение
синхронизации рассчитано на один экземпляр backend. Действие «Обновить»
перечитывает зависимости открытого экрана независимо от их свежести.
Для проверки конкретной суммы используйте
[руководство по расхождениям](how-to/investigate-discrepancy.md).