spec: complete persist-tbank-data implementation
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
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.
This commit was merged in pull request #16.
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
## 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. Числовые результаты будут получены задачами
|
||||
проверки, а не вписаны заранее.
|
||||
Reference in New Issue
Block a user