Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 11s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Record the approved proposal, delta specifications, design and 27 verified implementation tasks. Keep the change active; archiving and main-spec synchronization are deferred.
567 lines
60 KiB
Markdown
567 lines
60 KiB
Markdown
## Context
|
||
|
||
Основание — принятый [ADR 017](../../../../docs/adr/017-local-tbank-reads-and-background-sync.md)
|
||
и [дельты поведения](specs/tbank-data-sync/spec.md). Это детализация согласованной
|
||
модели для реализации. Компоненты приложения сейчас не изменены.
|
||
|
||
Исследованы локальные владельцы после чтения AGENTS каждого репозитория.
|
||
Свидетельства относятся к checkout на 2026-09-24, а не к обещаниям API:
|
||
|
||
| Граница | Текущий owner и переиспользуемое поведение |
|
||
| --- | --- |
|
||
| Хранение | `backend/prisma/schema.prisma` и `src/prisma/prisma.service.ts`: SQLite/Prisma, пользователи, сессии, подключение и ручные карты; T-Bank datasets отсутствуют. Условия владения и транзакции уже применяются в `modules/bank-cards/infrastructure/bank-cards.repository.ts`. |
|
||
| Доступ | `backend/src/integrations/tbank/tbank-access.service.ts`: `withSupportedAccounts` и `withAccountForConnection` вызывают `getOpenAccounts`; generation вычисляется из encryptedToken. `tbank-account.policy.ts` владеет допустимыми типами счетов. |
|
||
| Интеграция | `backend/src/integrations/tbank/tbank.client.ts`: общий FIFO, лимит 5 RPC/с по умолчанию, максимум 50 ожидающих, deadline и ограниченные повторы; повторный limiter не нужен. |
|
||
| Личный источник | `portfolio/application/portfolio.service.ts` хранит RAM-снимок 30 с / fallback 5 мин; `operations/application/brokerage-operations.service.ts` — страницы 30 с и обход курсоров. `income/income-analytics.service.ts` строит selected/previous/yearAgo через общий сборщик операций. Пути внутри `backend/src/modules/investments/`. |
|
||
| Общий источник | `backend/src/integrations/tbank/instrument-metadata.service.ts` уже разделяет общие metadata и pending lookup. Каталоги stocks/bonds смешивают общую информацию и личную позицию в user+generation RAM-кэше. Market services режут свечи по 365 суток. |
|
||
| Выплаты и смежные данные | `upcoming-events/upcoming-events.service.ts` отдельно получает portfolio; stock dividends приходят из T-Bank, bond events — из MOEX. Stock company use case получает asset description/fundamentals; bond metrics включают MOEX. Источники не подменяются. |
|
||
| Frontend | `frontend/src/entities/*/api` владеют generated queries; page VM собирает зависимости. `features/instrument-details/model/section.ts` знает только HTTP/query lifecycle. `features/tbank-connection/model/connection-boundary.ts` уже сбрасывает финансовую границу после успешной mutation. |
|
||
| Контракт | Backend DTO/controllers/Swagger — editable source. `frontend/src/shared/api/spec/openapi.json` — явно передаваемый snapshot. Accounts и search сейчас массивы; общего sync/coverage API нет. |
|
||
|
||
Исходный proto допускает изменение operation ID
|
||
(`backend/src/integrations/tbank/proto/contracts/operations.proto:496`),
|
||
но проект явно принимает стабильность. `is_complete` свечи задаёт источник
|
||
(`marketdata.proto:438`). В `operations.proto:472–489` не определены
|
||
inclusive/exclusive границы, срок жизни cursor и значение пропущенного `from`.
|
||
Эти пробелы не заменяются предположениями о гарантиях брокера.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:** один постоянный источник чтения в существующей БД; независимые
|
||
полнота, свежесть и состояние работы; атомарная публикация согласованного
|
||
набора; безопасный перезапуск; отсутствие RPC на пути ответа заполненной БД;
|
||
контракт всех экранных зависимостей и минимальный наблюдаемый worker.
|
||
|
||
**Non-Goals:** Redis, новый сервер БД, отдельный сервис очередей, cron,
|
||
распределённое выполнение, версии каждого портфельного снимка, live
|
||
подтверждение доступа при каждом чтении, архив отключённых счетов,
|
||
новый frontend store, изменение денежных правил или источников MOEX.
|
||
|
||
## Decisions
|
||
|
||
### 1. Существующие domain owners читают БД; RPC принадлежит синхронизации
|
||
|
||
Сохраняются controllers, application services, нормализация Money и domain
|
||
политики. Их read path получает сохранённые источники и согласованное
|
||
metadata, а запрос работы передаёт небольшому T-Bank sync coordinator.
|
||
PortfolioService становится общим владельцем последнего полного portfolio
|
||
для accounts summary, личных позиций инструментов и будущих событий.
|
||
Сборщик операций становится одним локальным источником ledger/income;
|
||
filtered HTTP page больше не является единицей источниковой полноты.
|
||
Рассчитанные доходы не требуют отдельной постоянно дублируемой таблицы.
|
||
|
||
Синхронизация использует существующие TbankClient, metadata mapping,
|
||
market-window splitting, точную денежную арифметику и MOEX adapters.
|
||
Нормализованные числа сохраняются десятичными строками, не float.
|
||
Presentation metadata не блокирует выдачу финансового снимка; fallback
|
||
UID/FIGI/name остаётся доступен до её фоновой загрузки.
|
||
|
||
Синхронная проверка token при PUT подключения сохраняется. Успешно полученный
|
||
в этой проверке список можно сразу опубликовать как membership нового
|
||
подключения в той же транзакции, без второго GetAccounts.
|
||
|
||
### 2. Личные и общие модели в SQLite
|
||
|
||
Это логические группы таблиц, не универсальный JSON-кэш всех HTTP-ответов:
|
||
|
||
| Группа | Ключ, содержимое и инвариант |
|
||
| --- | --- |
|
||
| Подключение и membership | Существующий `TbankConnection` получает opaque `revision` и состояние известного отказа token. Список открытых поддерживаемых счетов принадлежит `(userId, revision)`; хранит accountId, type, name, openedOn, upstream order, fetchedAt, membershipRevision. Список заменяется целиком атомарно, включая успешный пустой. |
|
||
| Portfolio | `(userId, revision, accountId)` → последний полный нормализованный snapshot, fetchedAt и dataRevision. Допустимо хранить состав snapshot как versioned JSON: он читается целиком, не нужен отдельный исторический агрегат. |
|
||
| Operations | Уникальность `(userId, revision, accountId, brokerOperationId)`; индекс `(userId, revision, accountId, occurredAt, brokerOperationId)`, дополнительные instrument/category поля для фильтров. Полный исходный нормализуемый payload сохраняет точные поля, executed state и version маппинга. Cursor/пустая строка не заменяют отсутствующий ID. |
|
||
| Инструменты | Общий ключ provider + instrument UID; FIGI/ticker — aliases с существующими правилами неоднозначности. Metadata, company/fundamentals и catalog discovery имеют собственные успешные timestamps и coverage; отрицательный успешный lookup отличается от ошибки. |
|
||
| Рыночные данные | Общие quote/trading-status и необходимые price dependencies; дневная свеча уникальна по `(provider, instrumentUid, interval, time)`, содержит `isComplete`. Календари имеют source/type/instrument/range и исходные события. MOEX enrichment сохраняет собственный source и существующую семантику недоступности. |
|
||
| Последний полный прогноз | Private snapshot account events и instrument personal payout estimates по `(userId, revision, account scope, instrument при наличии, applied range)`, с dependency revisions, source timestamps и calculatedAt. Заменяется только после готовности всех обязательных источников; это fallback результата, не история portfolio. |
|
||
| Покрытие и работа | Завершённые диапазоны, fetchedThrough/fetchedAt, dataRevision, известная нижняя граница и её происхождение; pending intent, attemptId, processRunId, диапазон, стадия и прогресс страниц. Staging страниц текущей попытки изолирован от опубликованного набора. |
|
||
|
||
Общие таблицы не содержат user/account/quantity/estimatedAmount или token.
|
||
Личные payout estimates, holdings и фильтрованная личная история вычисляются
|
||
из разрешённых accounts и portfolio/operations. Статус пользовательской
|
||
работы хранится отдельно от общих instrument records; ошибка credentials
|
||
не становится общим отрицательным lookup.
|
||
|
||
FK и unique constraints обеспечивают идентичность; транзакции проверяют
|
||
владение и revision. Не создаются generic repository framework, event bus
|
||
или универсальный job engine. Точные SQL-имена выбираются по локальным
|
||
Prisma conventions без изменения этой модели.
|
||
|
||
### 3. Доступ без синхронного GetAccounts
|
||
|
||
Read path: локальная сессия → существующее подключение и его revision →
|
||
известная ошибка token → membership текущей revision → данные разрешённых
|
||
счетов. Token для этого не расшифровывается.
|
||
|
||
- Membership отсутствует: `200` с `data: null`, `availability: missing` и
|
||
состоянием discovery, без данных неизвестного accountId. После discovery
|
||
следующий read возвращает данные либо прежний `404`.
|
||
- Membership свежий: разрешённые данные читаются локально.
|
||
- Membership старше пяти минут: используется последнее успешное наблюдение,
|
||
обычный запрос ставит его фоновое обновление. Это ограниченная задержка
|
||
обнаружения отзыва доступа, принятая локальной моделью, не live authority.
|
||
- Успешный новый список немедленно ограничивает все последующие одиночные
|
||
и aggregate reads; добавленный ещё не загруженный счёт делает aggregate
|
||
неполным. Ответ «Все счета» использует одну согласованную DB read transaction
|
||
с фиксированной membershipRevision.
|
||
- Известный `INVALID_TBANK_TOKEN` блокирует личные reads и возвращает прежний
|
||
`400`; `409 TBANK_NOT_CONNECTED`, `404`, session/validation errors сохраняются.
|
||
Старые записи не удаляются вследствие transient failure, но не выдаются
|
||
вопреки известному отказу доступа. Исправление token выполняется существующим PUT.
|
||
|
||
При замене успешная транзакция назначает новую случайную revision, сохраняет
|
||
новый token и очищает/делает недоступными прежние private datasets/jobs
|
||
по существующей семантике invalidation. Delete остаётся идемпотентным и
|
||
каскадно убирает личные данные подключения; специальный архив не вводится.
|
||
Общие неперсональные данные могут оставаться.
|
||
|
||
Каждая публикация данных, coverage, ошибки, timestamp и результата работы
|
||
проверяет в той же короткой транзакции текущие connection revision и
|
||
attemptId. Проверка после RPC только в памяти недостаточна. Проверка нужна
|
||
и при общей публикации, начатой credentials старого подключения.
|
||
Поздняя попытка не вправе изменить даже freshness; результаты старой revision
|
||
отбрасываются. Транзакция никогда не удерживается во время сетевого вызова.
|
||
|
||
### 4. Интервалы и атомарная сверка операций
|
||
|
||
Внутри БД диапазоны — `[from, to)` в UTC. Public semantics не меняются:
|
||
журнал принимает действующие UTC timestamps, его default начинается 1 января
|
||
UTC; даты доходов включительны в `Europe/Moscow`, и существующий
|
||
`planIncomePeriod` вычисляет selected/previous/yearAgo. Преобразование границ
|
||
выполняется один раз до планирования. Полнота доходного месяца проверяется
|
||
по объединению UTC coverage, а не по совпадению названия месяца.
|
||
|
||
Источник по операциям загружается ограниченными календарными UTC-месяцами
|
||
и фрагментами нужных диапазонов. Верхняя граница текущей попытки фиксируется
|
||
в момент её создания. Для неизвестной inclusivity upstream применяется
|
||
перекрытие соседних окон с защитной секундой; записи локально отбираются
|
||
по точному `[from, to)` и дедуплицируются по исходному ID. Это защищает
|
||
стык, но не доказывает отсутствие будущих исправлений брокера.
|
||
|
||
Обычная полнота счёта строится только по всем EXECUTED операциям интервала
|
||
без category/instrument фильтров. UI filters и pagination применяются после
|
||
чтения. Ручной refresh личной истории инструмента может перечитать только
|
||
`instrumentUid + shown range`; его reconciliation удаляет отсутствующие
|
||
записи только этого predicate и не присваивает полноту всему счёту.
|
||
Уже полное общее покрытие может обслуживать такой subset; обратное неверно.
|
||
|
||
Каждая успешная страница записывается в staging attempt с page count и
|
||
прогрессом. При наличии полного прежнего набора staging никогда не виден
|
||
как его замена. Новые большие периоды становятся доступными по завершении
|
||
малых интервалов при первичном заполнении, поэтому экран может иметь `partial` coverage без показа
|
||
непроверенных страниц. Сбой, repeated/missing next cursor, malformed ID или
|
||
противоречащие версии одного ID внутри попытки оставляют интервал незавершённым.
|
||
Идентичные дубликаты страницы безопасно схлопываются.
|
||
|
||
Только успешное исчерпание всех страниц фиксированного интервала разрешает
|
||
транзакцию публикации: upsert новых/изменённых полей, удаление отсутствующих записей
|
||
в перечитанном predicate, публикация coverage и времени успешной сверки.
|
||
Account dataRevision увеличивается только при изменении rowset;
|
||
coverage/timestamp/status без изменения строк меняют только stateVersion.
|
||
Читатель видит старый либо новый набор,
|
||
не их смесь. Между интервалами частичная готовность допустима и явно видима;
|
||
сумма всего периода окончательна только при полном покрытии его зависимостей.
|
||
|
||
Для повторной сверки уже полного показанного периода единица публикации —
|
||
весь запрошенный scope, а не отдельный календарный chunk. Staging объединяет
|
||
его интервалы, сравнения и счета (`all`) в одну publication group; успешные
|
||
страницы/окна сохраняют прогресс, но commit/upsert/delete/coverage всех
|
||
участников выполняются только после успеха группы. Любая ошибка оставляет
|
||
весь прежний результат. Такое же правило действует для ручного refresh
|
||
показанного диапазона свечей. Первичная загрузка ранее отсутствующих данных
|
||
по малым интервалам остаётся инкрементальной. Пересекающиеся работы
|
||
присоединяются к группе и не публикуют её часть независимо. DB transaction
|
||
короткая и локальная; RPC выполняются до неё, а staging не держится в RAM.
|
||
|
||
Для изменяемого конца `to=now` полнота означает «все операции по состоянию
|
||
на `asOf` последней успешной сверки». Новый ещё не наблюдённый хвост времени
|
||
в пределах того же текущего месяца и TTL не считается пропуском, запускающим
|
||
новую загрузку; ответ сохраняет исходный `asOf`, не утверждая покрытие будущих
|
||
операций. Настоящие пропуски до `asOf`, новый явно запрошенный исторический
|
||
диапазон и переход месяца этому исключению не подчиняются.
|
||
Текущий месяц обновляется целиком до зафиксированного now при истечении
|
||
пяти минут, чтобы учесть изменения уже виденных операций. После смены месяца
|
||
его покрытие до прежнего now не распространяется до конца месяца:
|
||
следующий подходящий запрос завершает хвост. Полные прошлые интервалы
|
||
не сверяются автоматически. Успешная наблюдённая смена полей того же ID
|
||
обновляет запись и производные результаты; поиск сменившихся ID отсутствует.
|
||
|
||
### 5. Планирование, приоритет и восстановление
|
||
|
||
Поддерживаемая поставка — один backend process с существующей SQLite.
|
||
Запрос сохраняет intent, возвращает локальные данные и передаёт выполнение
|
||
in-process coordinator. На startup нет обхода очереди или запуска импорта.
|
||
Process получает случайный runId; следующий подходящий запрос перехватывает
|
||
незавершённую работу другого runId новой attemptId. Persisted provider cursor
|
||
не переиспользуется после перезапуска: незавершённый интервал начинается
|
||
с первой страницы, staging прежней попытки очищается. Завершённые интервалы
|
||
и опубликованные данные сохраняются.
|
||
|
||
Приоритеты: discovery доступа → нужные экрану зависимости и интервалы,
|
||
включая comparisons → остальная история счёта и рыночная история его бумаг.
|
||
Между окнами/страницами coordinator отдаёт приоритет новой экранной работе;
|
||
уже отправленный RPC не прерывается ради перестановки. Один producer
|
||
догрузки не ставит тысячи RPC заранее: страницы идут последовательно,
|
||
новые окна планируются постепенно, через прежний bounded limiter.
|
||
Очередь RPC и bounded retry не дублируются на уровне worker.
|
||
|
||
Ключ personal work: user + connectionRevision + account + source kind +
|
||
normalized interval/predicate. Координатор сериализует публикации одного
|
||
account source и для обычной загрузки вычитает покрытые/выполняемые
|
||
пересечения; одновременно запрошенные остатки объединяет. Force refresh
|
||
не вычитает полное покрытие: он обязан перечитать запрошенный target,
|
||
но присоединяется к уже выполняемой подходящей загрузке. Повторный force refresh присоединяется к
|
||
подходящей активной попытке, уже получающей полный запрошенный диапазон;
|
||
TTL при этом не отменяет запрос. Новая область расширяет pending intent.
|
||
|
||
Shared work использует provider/instrument/source/range identity.
|
||
Общий успешный результат переиспользуется независимо от пользователя.
|
||
У каждого наблюдателя свой авторизованный handle, без выдачи чужих job IDs
|
||
и credentials errors. Ошибка token инициатора завершает его попытку, не
|
||
портит общий источник; новый допустимый запрос другого пользователя может
|
||
запустить попытку со своими credentials. Старый job не перехватывает token
|
||
другого пользователя сам.
|
||
|
||
Остальная история начинается с известной даты открытия; если её нет,
|
||
используется существующая техническая граница `1970-01-01T00:00:00Z`,
|
||
явно помеченная как requested floor, а не доказанная дата открытия/минимум
|
||
источника. Сохраняются confirmed intervals и `historyBoundary: unknown`;
|
||
«вся история загружена» не показывается до подтверждения нижней границы.
|
||
Отказ источника за пределами доступного диапазона сохраняется как ограничение,
|
||
не как пустой полный интервал. Отсутствие `from` не используется как
|
||
недокументированное обещание all-time. Это не мешает полной готовности
|
||
подтверждённых периодов экрана.
|
||
|
||
После transient failure попытка заканчивается с error; новый обычный запрос
|
||
может возобновить недостающее/устаревшее, ручной — повторить независимо от TTL.
|
||
Наблюдение результата этого не делает. Logout/уход останавливает наблюдение
|
||
frontend, но не отменяет уже запрошенную допустимую серверную работу.
|
||
Замена/delete подключения останавливает публикацию по revision fence.
|
||
|
||
### 6. Общие market data и производные выплаты
|
||
|
||
Quotes и portfolio обновляются через минуту; metadata/catalog/company и
|
||
календари — через сутки. Freshness принадлежит конкретному source/range,
|
||
поэтому успешный lookup одного инструмента не делает свежим весь каталог.
|
||
Catalog discovery сохраняет успешный набор UID/порядок отдельно от
|
||
enrichment по нормализованному поисковому query и типу инструмента, сохраняя
|
||
существующий предел результатов. `findShares/findBonds` не загружают весь
|
||
каталог: успешный поиск SBER не подтверждает результат GAZP. Новый query
|
||
без подтверждённого покрытия запускает discovery, а не возвращает ложный
|
||
пустой результат. Metadata уже известных UID переиспользуется; повтор того
|
||
же свежего полного query не вызывает discovery RPC. Отсутствие enrichment
|
||
не превращает найденный набор в пустой поиск.
|
||
|
||
Свечи сохраняют исходную `isComplete`, диапазоны и `historyFrom`.
|
||
Переиспользуются окна до 365 суток. Завершённый диапазон без свечей хранится
|
||
как проверенное пустое покрытие. Текущий UTC-день всегда изменяемый,
|
||
включая пустое окно до торгов; его TTL — минута. Смена UTC-дня сама не
|
||
закрывает ни пустое открытое окно, ни незавершённую свечу: следующий запрос
|
||
повторяет прошедшее окно; успешный закрытый пустой диапазон подтверждает
|
||
отсутствие, для имеющейся свечи нужно `isComplete=true`.
|
||
|
||
Известный `first_1dayCandleDate` определяет нижнюю границу all-range.
|
||
Если его нет, сохраняется существующее `*_HISTORY_START_UNAVAILABLE`,
|
||
не создаётся ложная полная история. Предзагрузка касается бумаг сохранённого
|
||
текущего портфеля и использует ту же общую coverage, что открытый график.
|
||
|
||
События инструментов сохраняются без пользовательской ожидаемой суммы.
|
||
Календари купонов/дивидендов и company data охватываются общим read path;
|
||
future events используют сохранённый portfolio и исходный календарь,
|
||
сохраняя неопределённость прогноза, cancelled-dividend и offer semantics.
|
||
MOEX bond events/metrics остаются MOEX: необходимые для экрана результаты
|
||
также сохраняются и обновляются в этой работе, без нового MOEX scheduler.
|
||
Недоступность optional enrichment не делает финансовые данные ошибочными;
|
||
неполнота обязательного календаря не выдаётся за полный пустой прогноз.
|
||
Последний полный прогноз/личная оценка сохраняется отдельным private
|
||
snapshot по показанному scope/range. Если новый portfolio добавил бумагу,
|
||
а её календарь не загрузился, следующий read, в том числе после restart,
|
||
возвращает прежний полный forecast с его прежними dependency timestamps и
|
||
ошибкой обновления. При отсутствии такого snapshot возвращается missing/partial
|
||
без окончательного списка. Готовый новый результат атомарно заменяет snapshot;
|
||
снимок с иным периодом или membership не выдаётся под новым scope, а доступ
|
||
всегда повторно ограничивается текущим membership.
|
||
|
||
### 7. Публичный контракт и наблюдение работы
|
||
|
||
**Contract Gate Decision: required.** Меняется wire shape всех T-Bank data
|
||
GET, включая bare-array accounts и stock/bond search. Backend сохраняет
|
||
маршруты и тип внутреннего payload, добавляя единый envelope:
|
||
|
||
```text
|
||
{ data: T | null, meta: {
|
||
resourceKey, dataRevision, stateVersion,
|
||
availability: missing | partial | complete,
|
||
freshness: unknown | fresh | stale, lastSuccessfulAt: timestamp | null,
|
||
sync: { id: opaque | null, state: idle | queued | running | failed,
|
||
errorCode: safeCode | null },
|
||
coverage?: { requested, completeRanges, missingRanges,
|
||
asOf, historyBoundary, historyState },
|
||
dependencies?: [{ resourceKey, availability, freshness,
|
||
lastSuccessfulAt, sync, coverage?, unavailableReason? }]
|
||
} }
|
||
```
|
||
|
||
Connection generation/revision остаётся внутренней: существующая security
|
||
policy запрещает её в API/logs/errors, даже если она случайная. Frontend
|
||
использует свою существующую session/connection boundary.
|
||
`resourceKey` — нормализованная идентичность запроса без user secrets и
|
||
connection generation. `dataRevision` меняется при публикации данных;
|
||
изменение статуса работы не меняет страницу операций. Availability означает
|
||
готовность обязательных источников данного результата, а не обещание полноты
|
||
рынка или будущих выплат. `data:null` используется для missing и для
|
||
неполных финансовых агрегатов; `partial` ledger/candles может содержать
|
||
подтверждённые части. Полнота selected/comparisons задаётся отдельно:
|
||
analytics `data` доступен после полного selected, а `previous` и
|
||
`yearComparison` nullable до готовности соответствующего сравнения.
|
||
`meta.dependencies` различает missing/loading/failed comparison и
|
||
заведомо недоступное по календарю/границе истории сравнение; последнее
|
||
имеет отдельный `unavailableReason` и не планируется как пропуск.
|
||
Это явное изменение вложенного analytics DTO, а не только envelope.
|
||
|
||
Для зависимостей с разными TTL metadata содержит их состояния по
|
||
resourceKey; freshness производного результата — худшее обязательное
|
||
состояние, lastSuccessfulAt — самый ранний из использованных успешных
|
||
источников. Чтение/`calculatedAt`/`observedAt` не продлевает source freshness.
|
||
Portfolio `data.lastSuccessfulAt` и `data.isStale` сохраняются для текущих
|
||
consumers и отображают тот же portfolio source, без прежнего пятиминутного
|
||
предела хранения. Error фонового обновления не меняет успешный timestamp
|
||
и сама по себе не отменяет ещё действующий TTL.
|
||
|
||
| Операция | Контракт |
|
||
| --- | --- |
|
||
| Обычный GET существующего resource | `200` envelope, в том числе cold/partial; планирует missing/stale зависимости. До постановки проверяет auth, connection и известную принадлежность. |
|
||
| Тот же GET с `sync=observe` | Только локальное чтение; никогда не создаёт и не возобновляет work, даже при missing/stale/error. Нужен для получения изменившихся данных после status observation. |
|
||
| `POST /api/v1/tbank-sync` | Валидированный screen descriptor, force по TTL; `202` с авторизованным work handle и normalized scope. При совпадении присоединяет к work. Приём не означает успех. |
|
||
| `GET /api/v1/tbank-sync/:id` | Только статус разрешённого handle, progress, changed resource revisions и terminal outcome; никаких новых запусков/повторов. Чужой/прежней connection handle — `404`. |
|
||
|
||
Descriptor — discriminated union `screen: accounts | account | operations |
|
||
income | events | stocks | bonds | stock | bond`. Поля разрешены только
|
||
соответствующему screen: account scope (конкретный ID/`all`), applied period,
|
||
filters, instrumentUid, candle period/range. Сервер использует прежние
|
||
валидаторы дат/лимитов/UID и сам раскрывает dependency graph; произвольного
|
||
списка RPC/таблиц/чужого userId во входе нет. Зафиксированный now и нормализованный
|
||
scope возвращаются клиенту, чтобы ручная операция не переезжала на другой период.
|
||
|
||
| Экран | Зависимости force refresh |
|
||
| --- | --- |
|
||
| Accounts | Membership + portfolio summary каждого текущего доступного счёта; metadata показанных позиций по необходимости. Не вся история операций. |
|
||
| Account / all overview | Membership, portfolio, показанный recent-ledger range, current-month income, upcoming events и необходимые instrument metadata/calendars. |
|
||
| Operations | Применённые account scope и период; категории — локальная проекция. Пагинация не расширяет источниковой период. |
|
||
| Income | Применённый период + previous/yearAgo из существующего planner, membership, требуемая metadata выплат. |
|
||
| Events | Membership, portfolio и прогноз применённого включительного from/to из сохранённых календарей; default from=today UTC, to=from+30 дней. |
|
||
| Stocks / bonds search | Общий каталог, результаты/enrichment текущего поиска и показываемые prices; без истории всего каталога. |
|
||
| Stock / bond | Detail/company либо metrics, quote и price dependencies, видимый candle range, payout calendar, личная position и применённый диапазон/инструмент personal operations. |
|
||
|
||
HTTP ошибки auth/input/access сохраняют `application/problem+json`.
|
||
`TBANK_UNAVAILABLE` из принятой фоновой работы передаётся как безопасный
|
||
`meta.sync.errorCode`, включая cold failure; `503` остаётся для запроса,
|
||
который не удалось принять/обслужить. Известный отказ credentials остаётся
|
||
`400 INVALID_TBANK_TOKEN`, не успешным envelope с недоступными private data.
|
||
Frontend различает server work error и transport failure.
|
||
|
||
Работа имеет terminal `succeeded | failed | cancelled` в status endpoint;
|
||
envelope отображает successful отсутствие активной работы как `idle`.
|
||
Статус имеет `stateVersion` нужных экрану ресурсов и отдельный прогресс background
|
||
history, поэтому готовые данные можно прочитать до окончания всей догрузки.
|
||
Frontend наблюдает только известный active handle на открытом экране
|
||
с интервалом 1–5 секунд и без автоматического retry failed work. После
|
||
изменения stateVersion или terminal status выполняется `sync=observe` GET.
|
||
После ухода/terminal status наблюдение прекращается. Это наблюдение уже
|
||
запрошенной работы, не периодический запуск импорта.
|
||
|
||
`stateVersion` — opaque идентичность состояния ответа, меняется при изменении
|
||
данных, coverage (включая пустое завершённое окно), successfulAt, ошибок,
|
||
работы и доступа; это не pagination revision. Он вычисляется из версий
|
||
зависимостей и состояния без выдачи внутренних connection identifiers.
|
||
Прогресс далёкой истории, не меняющий этот resource, не меняет его
|
||
stateVersion и не заставляет повторно читать уже готовый экран.
|
||
Истечение времени не создаёт работу: freshness вычисляется из timestamp.
|
||
|
||
Entity query owner записывает observe-ответ в ту же canonical cache identity
|
||
исходного GET; не создаётся второй cache по параметру observe. Query keys
|
||
берутся из generated owner, не собираются вручную. Все новые status/mutation
|
||
ответы участвуют в существующих session/connection boundary fences.
|
||
Кроме них owner использует latest-request sequence для каждого resource:
|
||
ответ более раннего запроса не заменяет уже принятый более поздний ответ.
|
||
При status notification запоминается ожидаемый stateVersion; observe-ответ
|
||
с прежней версией не публикуется, выполняется только новое observational read.
|
||
Подтверждённый access loss отменяет/инвалидирует соответствующие personal
|
||
queries и повышает локальную access epoch; даже поздний успешный ответ той же
|
||
connection, начатый до отзыва, не восстанавливает скрытые данные.
|
||
|
||
### 8. Локальная пагинация и финансовые результаты
|
||
|
||
Порядок операций сохраняет действующую сортировку с детерминированным
|
||
tie-break по accountId/operationId. Оpaque cursor связан с user, connection,
|
||
membershipRevision для all, нормализованными filters/period/limit и
|
||
account operations dataRevision. Любое изменение relevant rowset делает
|
||
cursor недействительным. Binding хранится серверно либо проверяется keyed
|
||
digest: connection generation не находится даже в декодируемом cursor payload.
|
||
Изменение rowset возвращает существующий `INVALID_OPERATIONS_CURSOR`;
|
||
исторические копии всех rowsets не хранятся. Первая реализация использует
|
||
revision на счёт: изменение другого периода тоже может сбросить его cursor;
|
||
более узкая revision нужна только при доказанно мешающих сбросах.
|
||
|
||
Frontend сбрасывает cursor stack на первую страницу, сохраняя applied filters
|
||
и объясняя обновление списка; ни страницы, ни суммы разных revisions не
|
||
склеиваются. `hasNext=false` означает конец текущей локальной выдачи,
|
||
а не полноту истории. Coverage/status без изменения строк cursor не ломают.
|
||
|
||
Income сохраняет точные signed coupon/dividend payments и существующие
|
||
comparison rules. Stable operation ID устраняет повторный финансовый учёт;
|
||
прежняя норма о разных entries с одинаковым ID явно изменена delta spec.
|
||
Analytics читает согласованный набор операций и coverage в read transaction.
|
||
Неполнота selected скрывает его окончательные totals; неполнота comparison
|
||
скрывает только его суммы/разницы и не создаёт нулевую базу. Уже опубликованный полный результат того же
|
||
period/context сохраняется при неудачной повторной сверке; данные другого
|
||
периода никогда не подписываются новым диапазоном.
|
||
|
||
### 9. Измеримый бюджет и стоимость интеграции
|
||
|
||
500 мс — цель, не текущий результат. Начальный профиль приёмки: один backend
|
||
process, локальная SQLite на SSD, 4 CPU / 8 GiB, 3 счёта, по 100 000 операций
|
||
за 10 лет, 200 инструментов и по 5 000 дневных свечей, включая пустые
|
||
интервалы; до 1 000 операций/200 доходных выплат на счёт в активном месяце.
|
||
Измеряются account/all reads, ledger page 50, monthly income с comparisons,
|
||
instrument detail/quote/company/metrics, year/all candles и payouts/search.
|
||
|
||
Бюджет — p95 ≤ 500 мс от входа HTTP на backend до окончания ответа при
|
||
10 одновременных клиентах и не менее 100 чтений каждой группы после прогрева.
|
||
Отдельно сохраняются p50/p99/max, размер ответа, состояние БД, hardware и
|
||
время первого DB-only ответа после перезапуска. Cold import исключён из
|
||
бюджета заполненной БД, но его HTTP ответ также не ожидает RPC. Больший
|
||
реальный профиль не объявляется измеренным по этим данным.
|
||
|
||
Изоляция от брокера проверяется задержанным/отказывающим T-Bank; источник
|
||
замеряемых данных — HTTP timing и счётчики RPC существующего logger. Для
|
||
fresh полной выборки ожидается 0 RPC; для 20 simultaneous stale requests —
|
||
один логический проход каждой нужной зависимости, с явным учётом pages и
|
||
разрешённых retries. После завершения, без screen/manual requests — 0 RPC.
|
||
Повторные старые полные intervals/candles, в том числе после restart, —
|
||
0 повторных RPC этих диапазонов. Сравнение с исходной реализацией выполняется
|
||
на одинаковом navigation trace; сохраняются абсолютные числа и доля снижения
|
||
`1 - new/old`, если baseline ненулевой. Измерения не требуют real tokens
|
||
в артефактах или логах.
|
||
|
||
## UI/UX Applicability and Package
|
||
|
||
**Applicability and scope:** `new/changed` — общие состояния и действие
|
||
обновления всех T-Bank экранов, без перестройки их содержимого.
|
||
|
||
**Package revision:** `1`. Пакет полностью inline; локальные PNG/SVG не
|
||
нужны, поскольку меняются информационные состояния и существующие controls,
|
||
а не композиция экранов. Self-hash и внешние визуальные зависимости отсутствуют.
|
||
|
||
**Screens, states and interactions:**
|
||
|
||
| Состояние | Представление и действие |
|
||
| --- | --- |
|
||
| Missing без результата | Существующий структурный skeleton только отсутствующих секций, «Загружаем данные», доступное busy-состояние; без нулей/«операций нет». |
|
||
| Partial period | Подтверждённые строки видны с «Период загружен не полностью». Финансовые totals/comparisons ждут полноты. «История догружается» отдельно от состояния показанного периода. |
|
||
| Complete fresh | Контент, время источника и «Обновить» в заголовке экрана. Истечение TTL само не запускает UI timer refresh. |
|
||
| Complete stale / active | Прежний контент и timestamp, «Обновляем» либо «Данные на …»; без skeleton поверх сумм. Повторная кнопка disabled для уже запрошенного текущего scope с доступным пояснением. |
|
||
| Failed with data | Прежний контент, исходное время, безопасное предупреждение и повторное «Обновить». Transport failure и server work failure различимы; статус не сообщает об успешном обновлении. |
|
||
| Failed without data | Локальное error-состояние секции, безопасный retry либо действие настроек согласно коду. |
|
||
| Complete empty | Только подтверждённое пустое состояние; empty portfolio не создаёт нулевые карточки, пустой доход объясняет отсутствие купонов/дивидендов. |
|
||
| Access lost / connection changed | Очистка недоступного personal view, настройки для token/connection, возврат к счетам для 404. Нельзя удерживать чужую/прежнюю revision под предупреждением. |
|
||
|
||
Accounts показывают freshness списка отдельно от локального состояния
|
||
portfolio summary каждой карточки. Account/all overview сохраняет независимые
|
||
секции. Operations сохраняет применённые фильтры; income не переименовывает
|
||
старый snapshot новым period. Stock/bond показывают состояния по sections,
|
||
не смешивая общий график с личными holdings/payout estimates. Детали candle
|
||
по-прежнему обозначают незавершённость.
|
||
|
||
**Responsive behavior:** на 320–375 px timestamp, предупреждение и кнопка
|
||
переносятся без горизонтального overflow и обрезания сумм; на широком экране
|
||
используют существующий header/section layout. Focus сохраняется после
|
||
обновления; button доступен с клавиатуры, текст статуса не зависит от цвета,
|
||
`aria-live=polite` объявляет смену стадии/ошибку, но не каждую страницу.
|
||
Skeleton не фокусируется; reduced motion сохраняется.
|
||
|
||
**Reused source and differences:** runtime Mantine controls, существующие
|
||
Section/VM patterns и generated query owners переиспользуются как код.
|
||
Это новый inline пакет, не reuse изменяемого или архивного design package;
|
||
его состояние полностью определено выше.
|
||
|
||
## Delivery Map
|
||
|
||
| Area | Status | Architectural outcome or N/A reason |
|
||
| --- | --- | --- |
|
||
| frontend | `affected` | Новый snapshot/types, metadata-aware entity queries/VMs всех экранов, scoped refresh и observe-only status, connection/cursor boundaries, inline UI package. |
|
||
| backend | `affected` | Prisma storage/migration, local access/read owners, durable interval sync/fencing, shared market sources, DTO и OpenAPI; evidence корректности, latency и RPC. |
|
||
| specs | `affected` | Этот change и семь delta capabilities; traceability реализации без преждевременного изменения main specs или чужих changes. |
|
||
| docs | `affected` | As-built freshness/storage/access/flows/API/glossary и актуальный статус ADR после поставки; не второй источник требований. |
|
||
|
||
## Solution Assessment
|
||
|
||
Trigger: storage, external integration, caching, concurrency и public API.
|
||
|
||
| Вариант | Fit, maturity, security, operating cost и migration cost |
|
||
| --- | --- |
|
||
| Существующие SQLite/Prisma + транзакции, unique keys и небольшой request-driven coordinator | Выбран: зрелая используемая БД, один процесс, отсутствие нового сервиса; ownership/fencing доступны в SQL. Миграция добавляет таблицы и revision. Специфичные broker intervals/reconciliation остаются доменной логикой. |
|
||
| Продлить RAM/TanStack кэш | Зрелый дешёвый механизм показа, но несовместим с durability и полнотой после restart. Остаётся presentation cache, не решение хранения. |
|
||
| Redis + BullMQ | Зрелые retries/locks/queues, но требует нового сервиса, persistence/security/backup и двойной согласованности с SQLite; его jobs всё равно не решают domain coverage/атомарную сверку. Нет нужды в распределённых workers, поэтому цена миграции/эксплуатации не оправдана. |
|
||
| Temporal либо аналогичный workflow server | Зрелое recovery, но отдельная инфраструктура и execution model для одной последовательности RPC; domain data transactions всё равно необходимы. Не соответствует минимальному одному backend. |
|
||
| Cron/планировщик | Зрелый механизм, но несовместим с явным запретом периодических запусков. |
|
||
|
||
Собственный код ограничен доменными интервалами, dependency planning,
|
||
staging/publish и coalescing; transport queue/retries и DB lifecycle уже
|
||
существуют. Существующий runtime не предоставляет durable job framework;
|
||
добавлять его ради этих конечных стадий не требуется. При переходе к нескольким
|
||
backend processes понадобится отдельное решение о durable claims/leases;
|
||
эта поставка не выдаёт processRunId за распределённую блокировку.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- Локальный membership не замечает отзыв доступа мгновенно. Он обновляется
|
||
по пятиминутной политике; известный отказ применяется немедленно.
|
||
- Stable operation ID противоречит возможностям upstream proto, но принят
|
||
как допущение; сменившийся ID может потребовать отдельной будущей сверки.
|
||
- Broker cursor не обещает snapshot isolation: полнота означает успешный
|
||
обход доступного интервала на момент сверки, не отсутствие последующих
|
||
исправлений. Staging защищает от наших частичных публикаций.
|
||
- Долгая первичная история расходует rate-limit и диск. Ограниченные окна,
|
||
приоритет экрана и отсутствие предварительной массовой очереди сдерживают
|
||
ресурсы; объём и время первой загрузки пока не измерены.
|
||
- Запись SQLite сериализуется. Короткие транзакции и индексы должны уложиться
|
||
в бюджет; это проверяется, а не считается доказанным архитектурой.
|
||
- Envelope — несовместимое wire изменение, поэтому backend/frontend
|
||
поставляются согласованной парой. API version path сам по себе не делает
|
||
старый frontend совместимым.
|
||
|
||
## Migration Plan
|
||
|
||
1. Зафиксировать текущие DB/API invariants и measurement baseline на
|
||
изолированной базе. Добавить additive schema с revision и пустыми новыми
|
||
datasets; текущие users/sessions/connection/manual-card записи сохранить.
|
||
У существующих подключений появляется новая revision без изменения token.
|
||
2. Реализовать storage/coordinator и local owners; старые RAM snapshots
|
||
не мигрировать как доказанно полную историю. Проверить crash/rollback и
|
||
fences до включения нового read path.
|
||
3. Backend публикует OpenAPI artifact; frontend получает его явно и
|
||
регенерирует типы локально. Совместно поставляется новый UI и server
|
||
read path, включая observer semantics и terminal errors.
|
||
4. Первые пользовательские запросы заполняют данные. Startup/deploy не
|
||
запускает импорт. После подтверждённых checks обновляются as-built docs;
|
||
specs остаётся единственным SDD authority.
|
||
|
||
### Rollback Contract
|
||
|
||
| Field | Decision |
|
||
| --- | --- |
|
||
| Status | `required` |
|
||
| Trigger | Cross-user/connection leak, частичная публикация финансовых данных, повреждение миграции, несовместимый API pair либо существенная регрессия latency. |
|
||
| Actions | Остановить новый worker/backend, сохранить согласованную копию SQLite, вернуть совместимую пару backend/frontend. Не запускать старую и новую write-модель одновременно. Отдельно проверить Prisma migration state; не удалять additive tables как способ отката. |
|
||
| Data safety | Сохранить новые datasets, progress и все прежние User/Session/TbankConnection/manual-card записи. Если старая версия не поддерживает новую Prisma schema, использовать совместимый rollback build либо восстановить backup в отдельной БД; нельзя перезаписать рабочую БД старой копией, потеряв новые ручные операции. |
|
||
| Verification | Проверить integrity/FK и равенство прежних пользовательских данных, невозможность публикации остановленного worker, auth/connection boundaries, согласованность API pair и повторное безопасное чтение/возобновление после возврата исправленной версии. |
|
||
|
||
## Open Questions
|
||
|
||
Блокирующих продуктовых вопросов нет. Неизвестны измеренные latency/объём
|
||
истории и provider semantics при отсутствующем `from`/openedDate. Они не
|
||
меняют выбранный путь: явные интервалы, replay с начала, unknown lower bound
|
||
и отсутствие ложного признака полной истории. Подтверждение минимальной
|
||
даты источника может уточнить только boundary metadata; до него используется
|
||
описанный безопасный fallback. Числовые результаты будут получены задачами
|
||
проверки, а не вписаны заранее.
|