chore(release): 2.0.0 #47
+2
-2
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
|
|||||||
|
|
||||||
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
||||||
для публичных consumers.
|
для публичных consumers.
|
||||||
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
|
||||||
request context и ограничивает поля событий. Jest unit tests, изолированные
|
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
|
||||||
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||||
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
|
|||||||
[validated environment](../../src/config/env.validation.ts) и
|
[validated environment](../../src/config/env.validation.ts) и
|
||||||
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
||||||
|
|
||||||
|
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
|
||||||
|
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
|
||||||
|
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
|
||||||
|
|
||||||
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
||||||
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
||||||
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||||
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||||
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||||
- RequestContextService и request middleware владеют correlation: серверный
|
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
|
||||||
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
|
||||||
|
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
|
||||||
|
или HTTP completion middleware не нужен.
|
||||||
Передавай существующий context, не создавай собственный UUID в каждом service.
|
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||||
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||||
tokens, cookie, Authorization, credentials, connection generation, raw body
|
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||||
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||||
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
|
|||||||
проверки browser/HTTP поведения, не только TypeScript.
|
проверки browser/HTTP поведения, не только TypeScript.
|
||||||
|
|
||||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
401, но не меняет cookie и сам по себе не отзывает session победителя.
|
||||||
|
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
|
||||||
|
Не вводи Web Locks, replay history/grace
|
||||||
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||||
сессию и очищает refresh cookie на HTTP-границе.
|
сессию и очищает refresh cookie на HTTP-границе.
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||||
|
|
||||||
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
|
||||||
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||||
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||||
|
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
|
||||||
|
его абсолютный wall-clock budget не является merge gate на shared runner.
|
||||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||||
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Audit runtime and development dependencies
|
- name: Audit runtime and development dependencies
|
||||||
run: npm audit --include=dev --audit-level=low
|
run: |
|
||||||
|
npm audit --omit=dev --audit-level=low
|
||||||
|
npm audit --include=dev --audit-level=high
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
needs: [commits, security]
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* remove investment stateVersion, queued sync state,
|
||||||
|
and tbank-sync status progress/resources. Consumers must observe
|
||||||
|
resources through sync=observe with the updated frontend contract.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
|
||||||
|
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
|
||||||
|
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
|
||||||
|
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
|
||||||
|
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
|
||||||
|
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
|
||||||
|
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
|
||||||
|
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
|
||||||
|
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
|
||||||
|
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
|
||||||
|
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
|
||||||
|
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
|
||||||
|
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
|
||||||
|
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
|
||||||
|
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
|
||||||
|
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
|
||||||
|
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
|
||||||
|
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
|
||||||
|
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
|
||||||
|
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
|
||||||
|
|
||||||
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
|
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
### ⚠ BREAKING CHANGES
|
||||||
|
|||||||
@@ -63,9 +63,12 @@ npm run verify
|
|||||||
Он запускает zero-warning lint,
|
Он запускает zero-warning lint,
|
||||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
|
||||||
выполните обе команды.
|
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
|
||||||
|
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
|
||||||
|
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
|
||||||
|
и не является функциональным merge gate.
|
||||||
|
|
||||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||||
@@ -81,12 +84,16 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
|||||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||||
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||||
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
||||||
`npm run verify` и `npm run test:e2e`. Используется только checkout
|
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
|
||||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||||
|
|
||||||
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||||
значениями и выполняет `npm audit --include=dev --audit-level=low`.
|
значениями, блокирует любой runtime advisory через
|
||||||
|
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
|
||||||
|
через `npm audit --include=dev --audit-level=high`. Moderate advisory
|
||||||
|
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
|
||||||
|
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
|
||||||
`.gitleaksignore` перечисляет только проверенные исторические
|
`.gitleaksignore` перечисляет только проверенные исторические
|
||||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||||
Новые коммиты этими исключениями не покрываются.
|
Новые коммиты этими исключениями не покрываются.
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/common/logging/request-logging.middleware.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -54,7 +49,7 @@
|
|||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/candle.service.ts": {
|
"src/modules/investments/instruments/application/candle.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 4
|
"count": 3
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 4
|
"count": 4
|
||||||
@@ -169,7 +164,7 @@
|
|||||||
},
|
},
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 3
|
"count": 1
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 7
|
"count": 7
|
||||||
|
|||||||
Generated
+16
-12
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.5",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
@@ -2163,6 +2163,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
@@ -12692,9 +12699,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
@@ -12702,6 +12709,10 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-agent": {
|
"node_modules/proxy-agent": {
|
||||||
@@ -13656,13 +13667,6 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sprintf-js": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/sql-escaper": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"test:cov": "npm test -- --coverage",
|
"test:cov": "npm test -- --coverage",
|
||||||
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||||
|
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
|
||||||
"test:sqlite": "node test/sqlite.cjs",
|
"test:sqlite": "node test/sqlite.cjs",
|
||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"typescript-eslint": "8.70.1"
|
"typescript-eslint": "8.70.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"proxy-addr": "2.0.8",
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.4.0"
|
"multer": "2.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
|
|||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
||||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
|
|
||||||
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
||||||
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
||||||
|
|||||||
+2
-36
@@ -1,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||||
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
}),
|
}),
|
||||||
LoggerModule.forRootAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
|
||||||
pinoHttp: {
|
|
||||||
level: config.getOrThrow('LOG_LEVEL'),
|
|
||||||
messageKey: 'message',
|
|
||||||
autoLogging: false,
|
|
||||||
genReqId: (request: unknown, response) => {
|
|
||||||
const existingId = (request as { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingId === 'string' ? existingId : randomUUID();
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
return requestId;
|
|
||||||
},
|
|
||||||
redact: [
|
|
||||||
'req.headers.authorization',
|
|
||||||
'req.headers.cookie',
|
|
||||||
'req.body',
|
|
||||||
],
|
|
||||||
serializers: {
|
|
||||||
req: (request: unknown) => {
|
|
||||||
const value = request as { id?: unknown; method?: unknown };
|
|
||||||
return {
|
|
||||||
id: typeof value.id === 'string' ? value.id : undefined,
|
|
||||||
method:
|
|
||||||
typeof value.method === 'string' ? value.method : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Options } from 'pino-http';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||||
|
const route: unknown = request.route;
|
||||||
|
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||||
|
typeof route.path === 'string'
|
||||||
|
? `${request.baseUrl}${route.path}`
|
||||||
|
: '<unmatched>';
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
route: path,
|
||||||
|
status: response.statusCode,
|
||||||
|
durationMs: Math.round(value.responseTime),
|
||||||
|
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||||
|
return {
|
||||||
|
level: config.getOrThrow('LOG_LEVEL'),
|
||||||
|
messageKey: 'message',
|
||||||
|
genReqId: (_request, response) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
response.setHeader('X-Request-ID', id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
customProps: (request) => {
|
||||||
|
const incoming = request.header('x-request-id');
|
||||||
|
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||||
|
},
|
||||||
|
customLogLevel: (_request, response) => {
|
||||||
|
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||||
|
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
customSuccessMessage: () => 'http.request.completed',
|
||||||
|
customErrorMessage: () => 'http.request.completed',
|
||||||
|
customSuccessObject: completion,
|
||||||
|
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||||
|
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||||
|
serializers: { req: () => undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||||
RequestContextService,
|
inject: [ConfigService],
|
||||||
RequestLoggingMiddleware,
|
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||||
StructuredLogger,
|
})],
|
||||||
],
|
providers: [StructuredLogger],
|
||||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
exports: [StructuredLogger],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
|
|
||||||
export type RequestLogContext = {
|
|
||||||
brokerageAccountId?: string;
|
|
||||||
clientRequestId?: string;
|
|
||||||
requestId: string;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextService {
|
|
||||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
|
||||||
|
|
||||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
|
||||||
return this.storage.run(context, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): RequestLogContext | undefined {
|
|
||||||
return this.storage.getStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
set(context: Partial<RequestLogContext>): void {
|
|
||||||
const current = this.storage.getStore();
|
|
||||||
if (current) Object.assign(current, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
|
||||||
|
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
|
||||||
|
|
||||||
describe('RequestLoggingMiddleware', () => {
|
|
||||||
it('returns a server ID and emits one safe completion event', () => {
|
|
||||||
const requestContext = {
|
|
||||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
const middleware = new RequestLoggingMiddleware(
|
|
||||||
requestContext as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const response = Object.assign(new EventEmitter(), {
|
|
||||||
statusCode: 200,
|
|
||||||
locals: {},
|
|
||||||
setHeader: jest.fn(),
|
|
||||||
});
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
const request: {
|
|
||||||
baseUrl: string;
|
|
||||||
header: jest.Mock<string>;
|
|
||||||
id?: string;
|
|
||||||
method: string;
|
|
||||||
route: { path: string };
|
|
||||||
} = {
|
|
||||||
method: 'GET',
|
|
||||||
baseUrl: '/api/v1',
|
|
||||||
route: { path: '/accounts/:id' },
|
|
||||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
|
||||||
};
|
|
||||||
middleware.use(request as never, response as never, next);
|
|
||||||
response.emit('finish');
|
|
||||||
|
|
||||||
expect(response.setHeader).toHaveBeenCalledWith(
|
|
||||||
'X-Request-ID',
|
|
||||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
|
||||||
);
|
|
||||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
||||||
expect(requestContext.run).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
|
||||||
}),
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'http.request.completed',
|
|
||||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { constants as httpStatus } from 'node:http2';
|
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
|
||||||
|
|
||||||
const uuid =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
|
||||||
constructor(
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly logger: StructuredLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
use(request: Request, response: Response, next: NextFunction): void {
|
|
||||||
const incoming = request.header('x-request-id');
|
|
||||||
const clientRequestId =
|
|
||||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
|
||||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
|
||||||
(request as unknown as { id: string }).id = requestId;
|
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
response.on('finish', () => {
|
|
||||||
const routeRequest: unknown = request;
|
|
||||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
|
||||||
?.path;
|
|
||||||
const route =
|
|
||||||
typeof routePath === 'string'
|
|
||||||
? `${request.baseUrl}${routePath}`
|
|
||||||
: '<unmatched>';
|
|
||||||
const context = {
|
|
||||||
method: request.method,
|
|
||||||
route,
|
|
||||||
status: response.statusCode,
|
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
|
||||||
...(response.locals.publicErrorCode
|
|
||||||
? { code: response.locals.publicErrorCode as string }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const level =
|
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
|
||||||
? 'error'
|
|
||||||
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
|
||||||
? 'warn'
|
|
||||||
: 'info';
|
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.requestContext.run(
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
...(clientRequestId ? { clientRequestId } : {}),
|
|
||||||
},
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
describe('StructuredLogger', () => {
|
describe('StructuredLogger', () => {
|
||||||
const context = { get: jest.fn() };
|
|
||||||
const pino = {
|
const pino = {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
@@ -12,8 +11,7 @@ describe('StructuredLogger', () => {
|
|||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('passes only allowlisted fields to Pino', () => {
|
it('passes only allowlisted fields to Pino', () => {
|
||||||
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.info('event', {
|
logger.info('event', {
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
@@ -23,7 +21,6 @@ describe('StructuredLogger', () => {
|
|||||||
|
|
||||||
expect(pino.info).toHaveBeenCalledWith(
|
expect(pino.info).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
requestId: 'request',
|
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
},
|
},
|
||||||
@@ -32,8 +29,7 @@ describe('StructuredLogger', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates event levels to Pino', () => {
|
it('delegates event levels to Pino', () => {
|
||||||
context.get.mockReturnValue(undefined);
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.event('debug', 'debug event');
|
logger.event('debug', 'debug event');
|
||||||
logger.event('warn', 'warn event');
|
logger.event('warn', 'warn event');
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||||
|
|
||||||
@@ -27,10 +25,7 @@ const allowedFields = new Set([
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StructuredLogger implements OnApplicationShutdown {
|
export class StructuredLogger implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(private readonly pino: PinoLogger) {}
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly pino: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
this.write('info', message, context);
|
this.write('info', message, context);
|
||||||
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
|
|||||||
): void {
|
): void {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||||
this.pino[level](
|
this.pino[level](
|
||||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
this.safeContext(context),
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => {
|
|||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
])(
|
])(
|
||||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
'recovers metadata after one retry for transient gRPC status %s',
|
||||||
async (code) => {
|
async (code) => {
|
||||||
requestRpc
|
requestRpc
|
||||||
.mockRejectedValueOnce({ code })
|
|
||||||
.mockRejectedValueOnce({ code })
|
.mockRejectedValueOnce({ code })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid');
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
await jest.advanceTimersByTimeAsync(999);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||||
await jest.advanceTimersByTimeAsync(3999);
|
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
await expect(result).resolves.toEqual(instrument);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
it('rejects metadata immediately when the queue is full', async () => {
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
requestRpc.mockResolvedValue({ instrument });
|
requestRpc.mockResolvedValue({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByFigi('token', 'figi');
|
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
expect(queue.run).not.toHaveBeenCalled();
|
||||||
queue.size = 49;
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'warn',
|
|
||||||
'tbank.rpc.retry',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
|
||||||
);
|
|
||||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not bypass queue admission after an upstream failure', async () => {
|
it('does not bypass queue admission after an upstream failure', async () => {
|
||||||
@@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => {
|
|||||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
await jest.advanceTimersByTimeAsync(2000);
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||||
queue.size = 0;
|
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(4000);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
|
||||||
queue.size = 50;
|
|
||||||
await Promise.all([
|
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
|
||||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
|
||||||
expect(logger.event).toHaveBeenLastCalledWith(
|
|
||||||
'error',
|
|
||||||
'tbank.rpc.failed',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
|
||||||
);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
|
|||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
|
|
||||||
const RATE_WINDOW_MS = 1_000;
|
const RATE_WINDOW_MS = 1_000;
|
||||||
const METADATA_MAX_ATTEMPTS = 3;
|
|
||||||
const RPC_MAX_ATTEMPTS = 2;
|
const RPC_MAX_ATTEMPTS = 2;
|
||||||
const MAX_QUEUE_SIZE = 50;
|
const MAX_QUEUE_SIZE = 50;
|
||||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
|
||||||
const RPC_RETRY_DELAY_MS = 1_000;
|
const RPC_RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
const requestDeadlineMs = 15_000;
|
const requestDeadlineMs = 15_000;
|
||||||
@@ -419,12 +417,6 @@ export class TbankClient {
|
|||||||
).then((response) => response.lastPrices ?? []);
|
).then((response) => response.lastPrices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
|
||||||
return this.getLastPrices(token, [uid]).then(
|
|
||||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||||
return this.call<{ tradingStatus?: string }>(
|
return this.call<{ tradingStatus?: string }>(
|
||||||
token,
|
token,
|
||||||
@@ -486,8 +478,7 @@ export class TbankClient {
|
|||||||
request: object,
|
request: object,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
try {
|
try {
|
||||||
@@ -508,34 +499,25 @@ export class TbankClient {
|
|||||||
const failureContext = queueFull
|
const failureContext = queueFull
|
||||||
? { code: TbankErrorCode.Unavailable }
|
? { code: TbankErrorCode.Unavailable }
|
||||||
: { grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull
|
const retryable = !queueFull && [
|
||||||
? metadataCall
|
status.DEADLINE_EXCEEDED,
|
||||||
: [
|
status.RESOURCE_EXHAUSTED,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.UNAVAILABLE,
|
||||||
status.RESOURCE_EXHAUSTED,
|
].includes(grpcCode);
|
||||||
status.UNAVAILABLE,
|
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||||
].includes(grpcCode);
|
|
||||||
if (retryable && attempts < maxAttempts) {
|
|
||||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||||
operation: method,
|
operation: method,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||||
setTimeout(
|
|
||||||
resolve,
|
|
||||||
metadataCall
|
|
||||||
? attempts * METADATA_RETRY_DELAY_MS
|
|
||||||
: RPC_RETRY_DELAY_MS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
|
||||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||||
import { Environment } from './config/env.validation';
|
import { Environment } from './config/env.validation';
|
||||||
import { shouldExposeSwagger } from './config/swagger.config';
|
import { shouldExposeSwagger } from './config/swagger.config';
|
||||||
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
|
|||||||
});
|
});
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidUnknownValues: true,
|
forbidUnknownValues: true,
|
||||||
|
|||||||
@@ -85,14 +85,9 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
const token = this.refreshToken(request);
|
||||||
const token = this.refreshToken(request);
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private readonly requestContext: RequestContextService) {
|
constructor(private readonly logger: PinoLogger) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<{ user?: AccessPayload }>();
|
.getRequest<{ user?: AccessPayload }>();
|
||||||
if (request.user?.sub) {
|
if (request.user?.sub) {
|
||||||
this.requestContext.set({ userId: request.user.sub });
|
this.logger.assign({ userId: request.user.sub });
|
||||||
}
|
}
|
||||||
return Boolean(allowed);
|
return Boolean(allowed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class BankCardsRepository {
|
|||||||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) {
|
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
|
||||||
@@ -189,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
).toEqual(['10.25', '-0.25']);
|
).toEqual(['10.25', '-0.25']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
|
||||||
|
seed([
|
||||||
|
{
|
||||||
|
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T21:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-15T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-14',
|
||||||
|
'2026-01-16',
|
||||||
|
);
|
||||||
|
expect(response.data?.buckets).toEqual([
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
coupon: { currency: 'RUB', value: '0.2' },
|
||||||
|
dividend: { currency: 'RUB', value: '-0.1' },
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||||
|
totalsByCurrency: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -232,13 +232,16 @@ function buckets(
|
|||||||
granularity: IncomeBucketGranularity,
|
granularity: IncomeBucketGranularity,
|
||||||
entries: BrokerageOperationDto[],
|
entries: BrokerageOperationDto[],
|
||||||
) {
|
) {
|
||||||
|
const datedEntries = entries.map((entry) => ({
|
||||||
|
entry,
|
||||||
|
date: moscowDate(new Date(entry.occurredAt)),
|
||||||
|
}));
|
||||||
return rangeBuckets(range, granularity).map((period) => ({
|
return rangeBuckets(range, granularity).map((period) => ({
|
||||||
period,
|
period,
|
||||||
totalsByCurrency: totals(
|
totalsByCurrency: totals(
|
||||||
entries.filter((entry) => {
|
datedEntries
|
||||||
const date = moscowDate(new Date(entry.occurredAt));
|
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||||
return date >= period.from && date <= period.to;
|
.map(({ entry }) => entry),
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
candleGaps,
|
candleGaps,
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
@@ -28,8 +27,6 @@ import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
|||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
|
||||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
|
||||||
const REPLACED_WINDOW_COUNT = 1;
|
const REPLACED_WINDOW_COUNT = 1;
|
||||||
const NEXT_WINDOW_OFFSET = 1;
|
const NEXT_WINDOW_OFFSET = 1;
|
||||||
|
|
||||||
@@ -55,7 +52,6 @@ type Producer = {
|
|||||||
};
|
};
|
||||||
const months = { month: 1, halfYear: 6, year: 12 };
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
const tailFreshForMs = 60_000;
|
const tailFreshForMs = 60_000;
|
||||||
const prefetchReadBatchSize = 100;
|
|
||||||
function scope(input: CandleInput) {
|
function scope(input: CandleInput) {
|
||||||
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -93,7 +89,6 @@ export class CandleService {
|
|||||||
private readonly queue: Producer[] = [];
|
private readonly queue: Producer[] = [];
|
||||||
private reservations: Promise<unknown> = Promise.resolve();
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
private running = false;
|
private running = false;
|
||||||
private readonly prefetches = new Map<string, Promise<void>>();
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CandleRepository,
|
private readonly repository: CandleRepository,
|
||||||
private readonly sources: CatalogEnrichmentService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
@@ -205,110 +200,6 @@ export class CandleService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetch(
|
|
||||||
userId: string,
|
|
||||||
revision: string,
|
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
|
||||||
) {
|
|
||||||
const key = JSON.stringify([
|
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
positions
|
|
||||||
.map((position) => [position.instrumentUid, position.type])
|
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
.sort(),
|
|
||||||
]);
|
|
||||||
const existing = this.prefetches.get(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
const work = this.prefetchPositions(userId, revision, positions).finally(
|
|
||||||
() => this.prefetches.delete(key),
|
|
||||||
);
|
|
||||||
this.prefetches.set(key, work);
|
|
||||||
return work;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async prefetchPositions(
|
|
||||||
userId: string,
|
|
||||||
revision: string,
|
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
|
||||||
) {
|
|
||||||
const now = new Date();
|
|
||||||
const unique = new Map(
|
|
||||||
positions
|
|
||||||
.filter(
|
|
||||||
(position) =>
|
|
||||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
|
||||||
)
|
|
||||||
.map((position) => [position.instrumentUid ?? '', position]),
|
|
||||||
);
|
|
||||||
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
|
||||||
[];
|
|
||||||
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
|
||||||
for (const instrumentType of ['share', 'bond'] as const) {
|
|
||||||
const uids = [...unique]
|
|
||||||
.filter(
|
|
||||||
([, position]) =>
|
|
||||||
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
|
||||||
)
|
|
||||||
.map(([uid]) => uid);
|
|
||||||
for (
|
|
||||||
let offset = FIRST_BATCH_OFFSET;
|
|
||||||
offset < uids.length;
|
|
||||||
offset += prefetchReadBatchSize
|
|
||||||
) {
|
|
||||||
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
|
||||||
details.push(
|
|
||||||
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
|
||||||
'detail',
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
coverage.push(...(await this.repository.coverage(batch)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
|
||||||
for (const [uid, position] of unique) {
|
|
||||||
const instrumentType: CandleInput['instrumentType'] =
|
|
||||||
position.type === 'STOCK' ? 'share' : 'bond';
|
|
||||||
const detail = byUid.get(uid);
|
|
||||||
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
|
||||||
const plan = planInvestmentScreen(
|
|
||||||
{ screen: 'account' },
|
|
||||||
{
|
|
||||||
membershipKnown: true,
|
|
||||||
accounts: [],
|
|
||||||
instruments: [
|
|
||||||
{
|
|
||||||
uid,
|
|
||||||
type: position.type as 'BOND' | 'STOCK',
|
|
||||||
historyFrom: history ? new Date(history) : null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!detail?.instrument ||
|
|
||||||
plan.sources.some((source) => source.kind === 'candles')
|
|
||||||
) {
|
|
||||||
const input = { uid, instrumentType, period: 'all' as const };
|
|
||||||
const range =
|
|
||||||
detail?.instrument && history
|
|
||||||
? rangeFor(input, detail.instrument, now)
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
!range ||
|
|
||||||
candleGaps(
|
|
||||||
range,
|
|
||||||
coverage.filter((row) => row.instrumentUid === uid),
|
|
||||||
now,
|
|
||||||
).length
|
|
||||||
) {
|
|
||||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request(
|
request(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
@@ -499,17 +390,6 @@ export class CandleService {
|
|||||||
this.reservations = finished.catch(() => undefined);
|
this.reservations = finished.catch(() => undefined);
|
||||||
if (!(await finished)) {
|
if (!(await finished)) {
|
||||||
this.queue.push(producer);
|
this.queue.push(producer);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (success && !producer.force && producer.input.period !== 'all') {
|
|
||||||
await this.request(
|
|
||||||
producer.attempt.userId,
|
|
||||||
producer.attempt.revision,
|
|
||||||
{ ...producer.input, period: 'all' },
|
|
||||||
producer.now,
|
|
||||||
false,
|
|
||||||
BACKGROUND_SYNC_PRIORITY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -122,6 +123,10 @@ export class BondIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
|
if (result.data?.positionAt) {
|
||||||
|
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||||
|
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||||
|
}
|
||||||
return result.data || !projected
|
return result.data || !projected
|
||||||
? result
|
? result
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ export class CandleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
coverage(uids: string[]) {
|
|
||||||
return this.prisma.tbankCandleCoverage.findMany({
|
|
||||||
where: { provider, instrumentUid: { in: uids }, interval },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reserve(
|
reserve(
|
||||||
owner: { revision: string; userId: string },
|
owner: { revision: string; userId: string },
|
||||||
scopeKey: string,
|
scopeKey: string,
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export { BondIncomeService } from './bonds/application/bond-income.service';
|
|
||||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
|
||||||
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
|
|||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -91,6 +92,10 @@ export class StockIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
|
if (result.data?.positionAt) {
|
||||||
|
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||||
|
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||||
|
}
|
||||||
return result.data || !projected
|
return result.data || !projected
|
||||||
? result
|
? result
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
|
|||||||
|
|
||||||
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
||||||
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||||
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
|
export const syncStateValues = ['idle', 'running', 'failed'] as const;
|
||||||
|
|
||||||
export class InvestmentReadQueryDto {
|
export class InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['observe'] })
|
@ApiPropertyOptional({ enum: ['observe'] })
|
||||||
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare dataRevision: string;
|
declare dataRevision: string;
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
declare stateVersion: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String })
|
@ApiPropertyOptional({ type: String })
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
|
|||||||
const stale = investmentResourceMeta(input);
|
const stale = investmentResourceMeta(input);
|
||||||
expect(stale.freshness).toBe('stale');
|
expect(stale.freshness).toBe('stale');
|
||||||
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
||||||
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
|
|||||||
});
|
});
|
||||||
expect(terminal.sync).toEqual(failed.sync);
|
expect(terminal.sync).toEqual(failed.sync);
|
||||||
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
||||||
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||||
|
|
||||||
export function investmentResourceMeta(input: {
|
export function investmentResourceMeta(input: {
|
||||||
@@ -31,7 +29,6 @@ export function investmentResourceMeta(input: {
|
|||||||
];
|
];
|
||||||
const sync =
|
const sync =
|
||||||
states.find((state) => state.state === 'running') ??
|
states.find((state) => state.state === 'running') ??
|
||||||
states.find((state) => state.state === 'queued') ??
|
|
||||||
states.find((state) => state.state === 'failed') ??
|
states.find((state) => state.state === 'failed') ??
|
||||||
ownSync;
|
ownSync;
|
||||||
const sourceFreshness = input.fetchedAt
|
const sourceFreshness = input.fetchedAt
|
||||||
@@ -71,19 +68,5 @@ export function investmentResourceMeta(input: {
|
|||||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||||
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||||
};
|
};
|
||||||
return {
|
return meta;
|
||||||
...meta,
|
|
||||||
stateVersion: createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
...meta,
|
|
||||||
// The requested window is identified by resourceKey. Its moving "now"
|
|
||||||
// boundary is not a change to saved coverage between status and GET.
|
|
||||||
coverage: meta.coverage
|
|
||||||
? { ...meta.coverage, requested: undefined }
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.digest('base64url'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
|
|||||||
priority: 'screen',
|
priority: 'screen',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!force &&
|
|
||||||
['account', 'events', 'income', 'operations'].includes(screen.screen)
|
|
||||||
) {
|
|
||||||
if (instrument.historyFrom) {
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: 'candles',
|
|
||||||
from: instrument.historyFrom,
|
|
||||||
to: now,
|
|
||||||
priority: 'history',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unavailable.push({
|
|
||||||
resourceKey: `candles/${instrument.uid}/all`,
|
|
||||||
reason: 'history_start_unavailable',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
||||||
sources.push(
|
sources.push(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
|
|
||||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
ForecastRepository,
|
ForecastRepository,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
} from '../infrastructure/forecast.repository';
|
} from '../infrastructure/forecast.repository';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ForecastService {
|
export class ForecastService {
|
||||||
@@ -73,23 +71,8 @@ export class ForecastService {
|
|||||||
dependencies
|
dependencies
|
||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
const payload = stored ? (stored.payload as T) : null;
|
|
||||||
const data =
|
|
||||||
payload &&
|
|
||||||
'positionIsStale' in payload &&
|
|
||||||
'positionAt' in payload &&
|
|
||||||
typeof payload.positionAt === 'string'
|
|
||||||
? {
|
|
||||||
...payload,
|
|
||||||
positionIsStale: tbankSourceNeedsRefresh(
|
|
||||||
'portfolio',
|
|
||||||
new Date(payload.positionAt),
|
|
||||||
new Date(Date.now()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: payload;
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: stored ? (stored.payload as T) : null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: input.scopeKey,
|
resourceKey: input.scopeKey,
|
||||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -101,10 +84,7 @@ export class ForecastService {
|
|||||||
? 'partial'
|
? 'partial'
|
||||||
: 'missing',
|
: 'missing',
|
||||||
fetchedAt: first ? new Date(first) : null,
|
fetchedAt: first ? new Date(first) : null,
|
||||||
freshForMs:
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
|
||||||
? STALE_FRESHNESS_MS
|
|
||||||
: Number.POSITIVE_INFINITY,
|
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import { setImmediate as immediate } from 'node:timers/promises';
|
import { setImmediate as immediate } from 'node:timers/promises';
|
||||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
|
|||||||
service = new PortfolioService(
|
service = new PortfolioService(
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
{ set: jest.fn() } as never,
|
|
||||||
metadata as never,
|
metadata as never,
|
||||||
repository as never,
|
repository as never,
|
||||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||||
{ event: jest.fn() } as never,
|
{ event: jest.fn() } as never,
|
||||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
|
||||||
);
|
);
|
||||||
access.local.mockResolvedValue({
|
access.local.mockResolvedValue({
|
||||||
revision: 'private',
|
revision: 'private',
|
||||||
@@ -221,15 +218,4 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(complete.data?.isStale).toBe(true);
|
expect(complete.data?.isStale).toBe(true);
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
|
||||||
repository.read.mockResolvedValue({
|
|
||||||
membershipAt: null,
|
|
||||||
membershipRevision: 0,
|
|
||||||
accounts: [],
|
|
||||||
work: [],
|
|
||||||
});
|
|
||||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +17,6 @@ import {
|
|||||||
type TbankPortfolio,
|
type TbankPortfolio,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { CandleService } from '../../instruments/instruments.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||||
@@ -38,23 +35,19 @@ const membershipFreshForMs = tbankSourceTtlMs.membership;
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
private readonly processRunId = randomUUID();
|
private readonly processRunId = randomUUID();
|
||||||
private readonly active = new Set<string>();
|
|
||||||
private readonly pending = new Map<string, Promise<void>>();
|
private readonly pending = new Map<string, Promise<void>>();
|
||||||
private reservations: Promise<unknown> = Promise.resolve();
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: PortfolioRepository,
|
private readonly repository: PortfolioRepository,
|
||||||
private readonly membership: TbankMembershipService,
|
private readonly membership: TbankMembershipService,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
private readonly candles: CandleService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async saved(userId: string, accountId: string, observe = false) {
|
async saved(userId: string, accountId: string, observe = false) {
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
if (!observe) {
|
if (!observe) {
|
||||||
const discovery = await this.membership.request(
|
const discovery = await this.membership.request(
|
||||||
@@ -123,28 +116,6 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!observe) {
|
|
||||||
void this.candles
|
|
||||||
.prefetch(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
(saved?.accounts ?? [])
|
|
||||||
.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
)
|
|
||||||
.flatMap(
|
|
||||||
(account) =>
|
|
||||||
(
|
|
||||||
account.portfolio?.payload as unknown as
|
|
||||||
PortfolioDto | undefined
|
|
||||||
)?.positions ?? [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() =>
|
|
||||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const membershipMeta = investmentResourceMeta({
|
const membershipMeta = investmentResourceMeta({
|
||||||
resourceKey: 'accounts/membership',
|
resourceKey: 'accounts/membership',
|
||||||
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -319,7 +290,7 @@ export class PortfolioService {
|
|||||||
revision,
|
revision,
|
||||||
accountId,
|
accountId,
|
||||||
this.processRunId,
|
this.processRunId,
|
||||||
this.active,
|
this.pending,
|
||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if (!requested) return null;
|
if (!requested) return null;
|
||||||
@@ -330,7 +301,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { id, attemptId } = requested.work;
|
const { id, attemptId } = requested.work;
|
||||||
this.active.add(attemptId);
|
|
||||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const attempt = { id, attemptId, userId, revision };
|
const attempt = { id, attemptId, userId, revision };
|
||||||
@@ -354,13 +324,6 @@ export class PortfolioService {
|
|||||||
for (const position of snapshot.positions) {
|
for (const position of snapshot.positions) {
|
||||||
await this.metadata.request(userId, revision, position);
|
await this.metadata.request(userId, revision, position);
|
||||||
}
|
}
|
||||||
if (!force) {
|
|
||||||
await this.candles.prefetch(
|
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
snapshot.positions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -377,7 +340,6 @@ export class PortfolioService {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.active.delete(attemptId);
|
|
||||||
this.pending.delete(attemptId);
|
this.pending.delete(attemptId);
|
||||||
});
|
});
|
||||||
this.pending.set(attemptId, done);
|
this.pending.set(attemptId, done);
|
||||||
@@ -387,18 +349,6 @@ export class PortfolioService {
|
|||||||
return reserved;
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
|
||||||
const result = await this.read(userId, accountId);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(userId: string): Promise<PortfolioDto> {
|
|
||||||
return this.get(userId, 'all');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
|
|||||||
@@ -44,15 +44,9 @@ export class ForecastRepository {
|
|||||||
data: { membershipRevision: { increment: 0 } },
|
data: { membershipRevision: { increment: 0 } },
|
||||||
});
|
});
|
||||||
if (!current.count) return null;
|
if (!current.count) return null;
|
||||||
}
|
} else if (!(await tx.tbankConnection.findFirst({
|
||||||
const connection = await tx.tbankConnection.findFirst({
|
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
|
||||||
where: {
|
}))) return null;
|
||||||
...owner,
|
|
||||||
membershipRevision: input.membershipRevision,
|
|
||||||
invalidToken: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!connection) return null;
|
|
||||||
const key = { ...owner, scopeKey: input.scopeKey };
|
const key = { ...owner, scopeKey: input.scopeKey };
|
||||||
const previous = await tx.tbankForecast.findUnique({
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class PortfolioRepository {
|
|||||||
revision: string,
|
revision: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
processRunId: string,
|
processRunId: string,
|
||||||
active: ReadonlySet<string>,
|
pending: ReadonlyMap<string, Promise<void>>,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
@@ -83,7 +83,7 @@ export class PortfolioRepository {
|
|||||||
if (
|
if (
|
||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === processRunId &&
|
previous.processRunId === processRunId &&
|
||||||
active.has(previous.attemptId)
|
pending.has(previous.attemptId)
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
|
||||||
import { ForecastService } from './application/forecast.service';
|
import { ForecastService } from './application/forecast.service';
|
||||||
import { PortfolioService } from './application/portfolio.service';
|
import { PortfolioService } from './application/portfolio.service';
|
||||||
import { ForecastRepository } from './infrastructure/forecast.repository';
|
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||||
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
|||||||
import { PortfolioController } from './presentation/portfolio.controller';
|
import { PortfolioController } from './presentation/portfolio.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
|
imports: [AuthModule, TbankModule],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
|
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
|
||||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
|
||||||
import { operationCategories } from '../operations/operations.api';
|
import { operationCategories } from '../operations/operations.api';
|
||||||
|
|
||||||
const MIN_ACCOUNT_ID_LENGTH = 1;
|
const MIN_ACCOUNT_ID_LENGTH = 1;
|
||||||
@@ -216,11 +215,6 @@ export class TbankSyncAcceptedDto {
|
|||||||
|
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
}
|
}
|
||||||
export class TbankSyncProgressDto {
|
|
||||||
@ApiProperty() declare completed: number;
|
|
||||||
@ApiProperty() declare total: number;
|
|
||||||
@ApiProperty() declare discovering: boolean;
|
|
||||||
}
|
|
||||||
export class TbankSyncStatusDto {
|
export class TbankSyncStatusDto {
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
||||||
@@ -232,10 +226,4 @@ export class TbankSyncStatusDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
||||||
null | string;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: TbankSyncProgressDto })
|
|
||||||
declare progress: TbankSyncProgressDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
|
|
||||||
declare resources: InvestmentResourceMetaDto[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,9 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
|
|
||||||
import { IncomeModule } from '../income/income.module';
|
|
||||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||||
import { InstrumentsModule } from '../instruments/instruments.module';
|
|
||||||
import { OperationsModule } from '../operations/operations.module';
|
import { OperationsModule } from '../operations/operations.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
|
||||||
import { TbankSyncController } from './tbank-sync.controller';
|
import { TbankSyncController } from './tbank-sync.controller';
|
||||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||||
import { TbankSyncService } from './tbank-sync.service';
|
import { TbankSyncService } from './tbank-sync.service';
|
||||||
@@ -19,10 +15,6 @@ import { TbankSyncService } from './tbank-sync.service';
|
|||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
InstrumentSourcesModule,
|
InstrumentSourcesModule,
|
||||||
InstrumentsModule,
|
|
||||||
BrokerageAccountsModule,
|
|
||||||
IncomeModule,
|
|
||||||
UpcomingEventsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [TbankSyncController],
|
controllers: [TbankSyncController],
|
||||||
providers: [TbankSyncService, TbankSyncRepository],
|
providers: [TbankSyncService, TbankSyncRepository],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
||||||
@@ -27,7 +27,7 @@ export class TbankSyncRepository {
|
|||||||
data: { membershipRevision: { increment: 0 } },
|
data: { membershipRevision: { increment: 0 } },
|
||||||
});
|
});
|
||||||
if (!valid.count) return null;
|
if (!valid.count) return null;
|
||||||
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`;
|
const scopeKey = `screen:${JSON.stringify(scope)}`;
|
||||||
const key = { userId, revision, scopeKey };
|
const key = { userId, revision, scopeKey };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
||||||
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
@@ -16,26 +14,17 @@ import {
|
|||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../integrations/tbank/tbank.api';
|
} from '../../../integrations/tbank/tbank.api';
|
||||||
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
|
|
||||||
import { IncomeAnalyticsService } from '../income/income-read.api';
|
|
||||||
import {
|
|
||||||
BondIncomeService,
|
|
||||||
StockIncomeService,
|
|
||||||
} from '../instruments/instrument-income.api';
|
|
||||||
import {
|
import {
|
||||||
CandleService,
|
CandleService,
|
||||||
CatalogDiscoveryService,
|
CatalogDiscoveryService,
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
} from '../instruments/instruments.api';
|
} from '../instruments/instruments.api';
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
|
||||||
import { planInvestmentScreen } from '../investment-sync-plan';
|
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsService,
|
|
||||||
BrokerageOperationsSyncService,
|
BrokerageOperationsSyncService,
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
} from '../operations/operations.api';
|
} from '../operations/operations.api';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
|
||||||
import { normalizeScreen } from './tbank-screen';
|
import { normalizeScreen } from './tbank-screen';
|
||||||
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
|
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
|
||||||
import {
|
import {
|
||||||
@@ -44,9 +33,6 @@ import {
|
|||||||
} from './tbank-sync.repository';
|
} from './tbank-sync.repository';
|
||||||
|
|
||||||
const FOREGROUND_CANDLE_PRIORITY = 0;
|
const FOREGROUND_CANDLE_PRIORITY = 0;
|
||||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
|
||||||
const PENDING_CHILD_COUNT = 0;
|
|
||||||
const COMPLETED_CHILD_COUNT = 1;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankSyncService {
|
export class TbankSyncService {
|
||||||
@@ -59,16 +45,10 @@ export class TbankSyncService {
|
|||||||
private readonly membership: TbankMembershipService,
|
private readonly membership: TbankMembershipService,
|
||||||
private readonly portfolio: PortfolioService,
|
private readonly portfolio: PortfolioService,
|
||||||
private readonly operations: BrokerageOperationsSyncService,
|
private readonly operations: BrokerageOperationsSyncService,
|
||||||
private readonly ledger: BrokerageOperationsService,
|
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly discovery: CatalogDiscoveryService,
|
private readonly discovery: CatalogDiscoveryService,
|
||||||
private readonly sources: CatalogEnrichmentService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
private readonly candles: CandleService,
|
private readonly candles: CandleService,
|
||||||
private readonly accounts: BrokerageAccountsService,
|
|
||||||
private readonly income: IncomeAnalyticsService,
|
|
||||||
private readonly events: UpcomingEventsService,
|
|
||||||
private readonly stockIncome: StockIncomeService,
|
|
||||||
private readonly bondIncome: BondIncomeService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async request(userId: string, input: InvestmentScreenDto) {
|
async request(userId: string, input: InvestmentScreenDto) {
|
||||||
@@ -368,7 +348,6 @@ export class TbankSyncService {
|
|||||||
: children.some((child) => child.state === 'cancelled')
|
: children.some((child) => child.state === 'cancelled')
|
||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
: 'succeeded';
|
: 'succeeded';
|
||||||
const resources = await this.observe(userId, intent.scope);
|
|
||||||
if (
|
if (
|
||||||
(await this.access.local(userId, intent.scope.accountId)).revision !==
|
(await this.access.local(userId, intent.scope.accountId)).revision !==
|
||||||
connection.revision
|
connection.revision
|
||||||
@@ -380,12 +359,6 @@ export class TbankSyncService {
|
|||||||
...intent,
|
...intent,
|
||||||
state,
|
state,
|
||||||
errorCode: failed,
|
errorCode: failed,
|
||||||
progress: {
|
|
||||||
completed: children.filter((child) => child.state !== 'running').length,
|
|
||||||
total: children.length,
|
|
||||||
discovering: !saved.progress.dispatched,
|
|
||||||
},
|
|
||||||
resources,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,106 +366,6 @@ export class TbankSyncService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
||||||
): Promise<TbankSyncStatusDto> {
|
): Promise<TbankSyncStatusDto> {
|
||||||
const resources: InvestmentResourceMetaDto[] = [];
|
|
||||||
const input = work.intent as Record<string, unknown>;
|
|
||||||
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
|
||||||
resources.push((await read).meta);
|
|
||||||
};
|
|
||||||
if (work.scopeKey === 'membership') {
|
|
||||||
await add(this.accounts.list(userId, true));
|
|
||||||
} else if (
|
|
||||||
input.source === 'portfolio' &&
|
|
||||||
typeof input.accountId === 'string'
|
|
||||||
) {
|
|
||||||
await add(this.portfolio.read(userId, input.accountId, true));
|
|
||||||
} else if (input.source === 'catalog') {
|
|
||||||
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
|
|
||||||
if (typeof input.query === 'string') {
|
|
||||||
await add(
|
|
||||||
this.discovery.search(
|
|
||||||
userId,
|
|
||||||
{
|
|
||||||
instrumentType: type,
|
|
||||||
query: input.query,
|
|
||||||
exactTicker: input.exactTicker === true,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
typeof input.uid === 'string' &&
|
|
||||||
typeof input.kind === 'string'
|
|
||||||
) {
|
|
||||||
const range =
|
|
||||||
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
|
|
||||||
? (JSON.parse(input.rangeKey) as string[])
|
|
||||||
: null;
|
|
||||||
const sources = await this.sources.readMany(
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
[input.uid],
|
|
||||||
true,
|
|
||||||
[
|
|
||||||
input.kind as Parameters<
|
|
||||||
CatalogEnrichmentService['readMany']
|
|
||||||
>[4] extends (infer K)[] | undefined
|
|
||||||
? K
|
|
||||||
: never,
|
|
||||||
],
|
|
||||||
range ? { from: range[0], to: range[1] } : undefined,
|
|
||||||
);
|
|
||||||
resources.push(...sources.map((source) => source.meta));
|
|
||||||
}
|
|
||||||
} else if (input.source === 'metadata') {
|
|
||||||
const identity = {
|
|
||||||
instrumentUid:
|
|
||||||
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
|
|
||||||
figi: typeof input.figi === 'string' ? input.figi : null,
|
|
||||||
};
|
|
||||||
const source = await this.metadata.read(userId, identity, true);
|
|
||||||
resources.push(
|
|
||||||
investmentResourceMeta({
|
|
||||||
resourceKey: work.scopeKey,
|
|
||||||
dataRevision: source?.dataRevision ?? '0',
|
|
||||||
availability: source?.complete ? 'complete' : 'missing',
|
|
||||||
fetchedAt: source?.fetchedAt ?? null,
|
|
||||||
freshForMs: Number.POSITIVE_INFINITY,
|
|
||||||
work,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
work.scopeKey.startsWith('candles:') &&
|
|
||||||
typeof input.uid === 'string'
|
|
||||||
) {
|
|
||||||
await add(
|
|
||||||
this.candles.read(
|
|
||||||
userId,
|
|
||||||
{
|
|
||||||
uid: input.uid,
|
|
||||||
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
|
|
||||||
period: input.period as 'all' | 'halfYear' | 'month' | 'year',
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
work.scopeKey.startsWith('operations:') &&
|
|
||||||
Array.isArray(input.intervals)
|
|
||||||
) {
|
|
||||||
for (const key of input.intervals as string[]) {
|
|
||||||
const [accountId, from, end, instrumentUid] = JSON.parse(
|
|
||||||
key,
|
|
||||||
) as string[];
|
|
||||||
await add(
|
|
||||||
this.ledger.read(userId, accountId, {
|
|
||||||
from,
|
|
||||||
to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(),
|
|
||||||
...(instrumentUid ? { instrumentUid } : {}),
|
|
||||||
sync: 'observe',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const observed = work.scopeKey.startsWith('operations:')
|
const observed = work.scopeKey.startsWith('operations:')
|
||||||
? await this.operations.observe(userId, work.id)
|
? await this.operations.observe(userId, work.id)
|
||||||
: work;
|
: work;
|
||||||
@@ -515,112 +388,6 @@ export class TbankSyncService {
|
|||||||
now: work.createdAt.toISOString(),
|
now: work.createdAt.toISOString(),
|
||||||
state,
|
state,
|
||||||
errorCode: observed.errorCode,
|
errorCode: observed.errorCode,
|
||||||
progress: {
|
|
||||||
completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT,
|
|
||||||
total: 1,
|
|
||||||
discovering: false,
|
|
||||||
},
|
|
||||||
resources,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async observe(userId: string, scope: InvestmentScreenDto) {
|
|
||||||
const resources: InvestmentResourceMetaDto[] = [];
|
|
||||||
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
|
||||||
resources.push((await read).meta);
|
|
||||||
};
|
|
||||||
const account = scope.accountId ?? 'all';
|
|
||||||
if (scope.screen === 'accounts') {
|
|
||||||
await add(this.accounts.list(userId, true));
|
|
||||||
}
|
|
||||||
if (scope.screen === 'account') {
|
|
||||||
await add(this.portfolio.read(userId, account, true));
|
|
||||||
await add(this.income.summary(userId, account, true));
|
|
||||||
await add(this.events.get(userId, account, { sync: 'observe' }));
|
|
||||||
}
|
|
||||||
if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) {
|
|
||||||
await add(
|
|
||||||
this.ledger.read(userId, account, {
|
|
||||||
from: scope.from,
|
|
||||||
to: scope.to,
|
|
||||||
instrumentUid: scope.instrumentUid,
|
|
||||||
categories: scope.categories,
|
|
||||||
limit: scope.limit,
|
|
||||||
sync: 'observe',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (scope.screen === 'income') {
|
|
||||||
await add(
|
|
||||||
this.income.analytics(userId, account, scope.from, scope.to, true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (scope.screen === 'events') {
|
|
||||||
await add(
|
|
||||||
this.events.get(userId, account, {
|
|
||||||
from: scope.from,
|
|
||||||
to: scope.to,
|
|
||||||
sync: 'observe',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const type =
|
|
||||||
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
|
|
||||||
if (scope.screen === 'stocks' || scope.screen === 'bonds') {
|
|
||||||
await add(
|
|
||||||
this.discovery.search(
|
|
||||||
userId,
|
|
||||||
{ instrumentType: type, query: scope.query ?? '' },
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
|
||||||
scope.instrumentUid
|
|
||||||
) {
|
|
||||||
const uid = scope.instrumentUid;
|
|
||||||
await add(this.discovery.detail(userId, type, uid, true));
|
|
||||||
await add(this.sources.section(userId, type, uid, 'quote', true));
|
|
||||||
await add(
|
|
||||||
this.sources.section(
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
uid,
|
|
||||||
type === 'share' ? 'company' : 'metrics',
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await add(
|
|
||||||
this.candles.read(
|
|
||||||
userId,
|
|
||||||
{
|
|
||||||
uid,
|
|
||||||
instrumentType: type,
|
|
||||||
period: scope.candlePeriod ?? 'month',
|
|
||||||
...(scope.candleRange
|
|
||||||
? {
|
|
||||||
range: {
|
|
||||||
from: new Date(scope.candleRange.from),
|
|
||||||
to: new Date(scope.candleRange.to),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await add(
|
|
||||||
type === 'share'
|
|
||||||
? this.stockIncome.readPosition(userId, uid, true)
|
|
||||||
: this.bondIncome.readPosition(userId, uid, true),
|
|
||||||
);
|
|
||||||
await add(
|
|
||||||
type === 'share'
|
|
||||||
? this.stockIncome.dividends(userId, uid, true)
|
|
||||||
: this.bondIncome.coupons(userId, uid, true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||||
|
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
|
||||||
import { decimal } from '../../../integrations/tbank/decimal';
|
import { decimal } from '../../../integrations/tbank/decimal';
|
||||||
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
|
|
||||||
import { UpcomingEventDto } from './upcoming-events.dto';
|
import { UpcomingEventDto } from './upcoming-events.dto';
|
||||||
|
|
||||||
export function dividendEvent(
|
export function dividendEvent(
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { UpcomingEventsService } from './upcoming-events.service';
|
|
||||||
@@ -20,12 +20,12 @@ import {
|
|||||||
type PortfolioPositionDto,
|
type PortfolioPositionDto,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
} from '../portfolio/portfolio.api';
|
} from '../portfolio/portfolio.api';
|
||||||
|
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
|
||||||
import {
|
import {
|
||||||
UpcomingEventDto,
|
UpcomingEventDto,
|
||||||
UpcomingEventsDto,
|
UpcomingEventsDto,
|
||||||
UpcomingEventsQueryDto,
|
UpcomingEventsQueryDto,
|
||||||
} from './upcoming-events.dto';
|
} from './upcoming-events.dto';
|
||||||
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
|
|
||||||
|
|
||||||
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
|
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+27
-17
@@ -1,12 +1,13 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
import { StructuredLogger } from '../src/common/logging/structured-logger.service';
|
import { loggingOptions } from '../src/common/logging/logging.module';
|
||||||
import { configureApp } from '../src/main';
|
import { configureApp } from '../src/main';
|
||||||
import { AuthService } from '../src/modules/auth/application/auth.service';
|
import { AuthService } from '../src/modules/auth/application/auth.service';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
@@ -16,10 +17,22 @@ type Problem = { code: string };
|
|||||||
|
|
||||||
describe('Authentication (e2e)', () => {
|
describe('Authentication (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
const logs: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
logs.length = 0;
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
|
}).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({
|
||||||
|
inject: [ConfigService],
|
||||||
|
factory: (config: Parameters<typeof loggingOptions>[0]) => ({
|
||||||
|
pinoHttp: {
|
||||||
|
...loggingOptions(config),
|
||||||
|
level: 'info',
|
||||||
|
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
|
||||||
|
},
|
||||||
|
assignResponse: true,
|
||||||
|
}),
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
@@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => {
|
|||||||
]);
|
]);
|
||||||
const winner = responses.find((response) => response.status === 200);
|
const winner = responses.find((response) => response.status === 200);
|
||||||
if (!winner) throw new Error('Expected one successful rotation');
|
if (!winner) throw new Error('Expected one successful rotation');
|
||||||
|
expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.set('Cookie', cookie)
|
.set('Cookie', cookie)
|
||||||
.expect(401);
|
.expect(401)
|
||||||
|
.expect((response) => expect(response.headers['set-cookie']).toBeUndefined());
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.set('Cookie', winner.headers['set-cookie'][0])
|
.set('Cookie', winner.headers['set-cookie'][0])
|
||||||
@@ -304,8 +319,6 @@ describe('Authentication (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a server request ID and logs one completion event', async () => {
|
it('exposes a server request ID and logs one completion event', async () => {
|
||||||
const logger = app.get(StructuredLogger);
|
|
||||||
const event = jest.spyOn(logger, 'event');
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get('/api/v1/auth/me')
|
.get('/api/v1/auth/me')
|
||||||
.set('Origin', 'http://localhost:5173')
|
.set('Origin', 'http://localhost:5173')
|
||||||
@@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => {
|
|||||||
expect(response.headers['access-control-expose-headers']).toContain(
|
expect(response.headers['access-control-expose-headers']).toContain(
|
||||||
'X-Request-ID',
|
'X-Request-ID',
|
||||||
);
|
);
|
||||||
expect(event).toHaveBeenCalledTimes(1);
|
expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
|
||||||
expect(event).toHaveBeenCalledWith(
|
expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
|
||||||
'warn',
|
]);
|
||||||
'http.request.completed',
|
|
||||||
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an unhandled error with safe public context and correlation', async () => {
|
it('logs an unhandled error with safe public context and correlation', async () => {
|
||||||
const error = jest.spyOn(PinoLogger.prototype, 'error');
|
|
||||||
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
|
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
|
||||||
jest
|
jest
|
||||||
.spyOn(app.get(AuthService), 'login')
|
.spyOn(app.get(AuthService), 'login')
|
||||||
@@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => {
|
|||||||
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
|
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(completed).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestId: response.headers['x-request-id'],
|
requestId: response.headers['x-request-id'],
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
status: 500,
|
status: 500,
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
level: 50,
|
||||||
|
message: 'http.request.completed',
|
||||||
}),
|
}),
|
||||||
'http.request.completed',
|
]);
|
||||||
);
|
expect(JSON.stringify(logs)).not.toContain(
|
||||||
expect(JSON.stringify(error.mock.calls)).not.toContain(
|
|
||||||
'SYNTHETIC_AUDIT_SECRET',
|
'SYNTHETIC_AUDIT_SECRET',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -7,12 +7,13 @@ const cwd = resolve(__dirname, '..');
|
|||||||
const available = readdirSync(__dirname)
|
const available = readdirSync(__dirname)
|
||||||
.filter((name) => name.endsWith('.e2e-spec.ts'))
|
.filter((name) => name.endsWith('.e2e-spec.ts'))
|
||||||
.sort();
|
.sort();
|
||||||
|
const performanceSuites = new Set(['tbank-read-baseline.e2e-spec.ts']);
|
||||||
const requested = process.argv.slice(2);
|
const requested = process.argv.slice(2);
|
||||||
const suites =
|
const suites =
|
||||||
requested.length === 0
|
requested.length === 0
|
||||||
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
|
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
|
||||||
: requested.length === 1 && requested[0] === '--all'
|
: requested.length === 1 && requested[0] === '--all'
|
||||||
? available
|
? available.filter((suite) => !performanceSuites.has(suite))
|
||||||
: requested;
|
: requested;
|
||||||
if (suites.some((suite) => !available.includes(suite))) {
|
if (suites.some((suite) => !available.includes(suite))) {
|
||||||
throw new Error('Unknown e2e suite');
|
throw new Error('Unknown e2e suite');
|
||||||
@@ -63,6 +64,7 @@ function run(args, env) {
|
|||||||
WL_E2E_DIRECTORY: dir,
|
WL_E2E_DIRECTORY: dir,
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'development',
|
||||||
NODE_OPTIONS: '--experimental-vm-modules',
|
NODE_OPTIONS: '--experimental-vm-modules',
|
||||||
|
LOG_LEVEL: 'error',
|
||||||
};
|
};
|
||||||
console.log(`SQLite ${suite}: ${dir}`);
|
console.log(`SQLite ${suite}: ${dir}`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
lastSuccessfulAt: null,
|
lastSuccessfulAt: null,
|
||||||
resourceKey: 'detail/uid',
|
resourceKey: 'detail/uid',
|
||||||
dataRevision: '1',
|
dataRevision: '1',
|
||||||
stateVersion: 'detail-v1',
|
|
||||||
sync: { id: null, state: 'idle', errorCode: null },
|
sync: { id: null, state: 'idle', errorCode: null },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,7 +272,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
).toBe(abandoned?.work.attemptId);
|
).toBe(abandoned?.work.attemptId);
|
||||||
await reopened.read('owner', input);
|
await reopened.read('owner', input);
|
||||||
await settled();
|
await settled();
|
||||||
expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1);
|
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||||
expect(
|
expect(
|
||||||
(await repository.work('owner', 'revision', 'candles:uid:month'))
|
(await repository.work('owner', 'revision', 'candles:uid:month'))
|
||||||
?.attemptId,
|
?.attemptId,
|
||||||
@@ -483,52 +482,20 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
|
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => {
|
it('loads only the requested period and reuses its saved coverage', async () => {
|
||||||
await prisma.tbankInstrument.create({
|
|
||||||
data: {
|
|
||||||
provider: 'tbank',
|
|
||||||
uid: 'opened',
|
|
||||||
instrumentType: 'share',
|
|
||||||
metadata: {},
|
|
||||||
fetchedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const service = createService();
|
const service = createService();
|
||||||
let release: (value: unknown[]) => void = () => {};
|
const input = {
|
||||||
const blocked = new Promise<unknown[]>((resolve) => {
|
uid: 'uid',
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
upstream.getCandles.mockReturnValueOnce(blocked);
|
|
||||||
await service.prefetch('owner', 'revision', [
|
|
||||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
|
||||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
|
||||||
{ instrumentUid: 'not-supported', type: 'FUND' },
|
|
||||||
{ instrumentUid: null, type: 'BOND' },
|
|
||||||
]);
|
|
||||||
for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) {
|
|
||||||
await delay(5);
|
|
||||||
}
|
|
||||||
expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid');
|
|
||||||
await service.read('owner', {
|
|
||||||
uid: 'opened',
|
|
||||||
instrumentType: 'share',
|
instrumentType: 'share',
|
||||||
period: 'month',
|
period: 'month',
|
||||||
});
|
} as const;
|
||||||
release([]);
|
await service.read('owner', input);
|
||||||
await settled();
|
await settled();
|
||||||
expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe(
|
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
|
||||||
'opened',
|
expect(await repository.work('owner', 'revision', 'candles:uid:all')).toBeNull();
|
||||||
);
|
|
||||||
expect(
|
|
||||||
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
|
|
||||||
).toEqual(new Set(['opened', 'uid']));
|
|
||||||
const count = upstream.getCandles.mock.calls.length;
|
const count = upstream.getCandles.mock.calls.length;
|
||||||
await service.prefetch('owner', 'revision', [
|
await service.read('owner', input);
|
||||||
{ instrumentUid: 'uid', type: 'STOCK' },
|
await settled();
|
||||||
]);
|
|
||||||
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
|
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
|
||||||
expect(
|
|
||||||
(await repository.work('owner', 'revision', 'candles:opened:all'))?.state,
|
|
||||||
).toBe('complete');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it('keeps status versions stable between observational reads of a moving period', async () => {
|
it('keeps data revisions stable while observational reads expose current coverage', async () => {
|
||||||
const observe = () =>
|
const observe = () =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
|
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
|
||||||
@@ -276,7 +276,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(second.meta.coverage?.requested).not.toEqual(
|
expect(second.meta.coverage?.requested).not.toEqual(
|
||||||
first.meta.coverage?.requested,
|
first.meta.coverage?.requested,
|
||||||
);
|
);
|
||||||
expect(second.meta.stateVersion).toBe(first.meta.stateVersion);
|
expect(second.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||||
await prisma.tbankOperationCoverage.updateMany({
|
await prisma.tbankOperationCoverage.updateMany({
|
||||||
data: {
|
data: {
|
||||||
to: new Date(Date.now() - 60_000),
|
to: new Date(Date.now() - 60_000),
|
||||||
@@ -285,10 +285,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
const changed = (await observe().expect(200))
|
const changed = (await observe().expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(changed.meta.stateVersion).not.toBe(second.meta.stateVersion);
|
expect(changed.meta.coverage?.asOf).not.toBe(second.meta.coverage?.asOf);
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('reads shared metadata from SQLite and changes stateVersion without invalidating a row cursor', async () => {
|
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
|
||||||
const before = (await get('limit=1&sync=observe').expect(200))
|
const before = (await get('limit=1&sync=observe').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
assert(before.data?.nextCursor);
|
assert(before.data?.nextCursor);
|
||||||
@@ -323,7 +323,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
const after = (await get('limit=1&sync=observe').expect(200))
|
const after = (await get('limit=1&sync=observe').expect(200))
|
||||||
.body as BrokerageOperationsResourceDto;
|
.body as BrokerageOperationsResourceDto;
|
||||||
expect(after.data?.items[0].logoName).toBe('saved.png');
|
expect(after.data?.items[0].logoName).toBe('saved.png');
|
||||||
expect(after.meta.stateVersion).not.toBe(before.meta.stateVersion);
|
|
||||||
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
|
||||||
await get(
|
await get(
|
||||||
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
|
||||||
|
|||||||
@@ -134,12 +134,6 @@ describe('T-Bank persisted read budget', () => {
|
|||||||
'bond',
|
'bond',
|
||||||
instruments.find((item) => item.uid === uid),
|
instruments.find((item) => item.uid === uid),
|
||||||
),
|
),
|
||||||
getLastPrice: (_token: string, uid: string) =>
|
|
||||||
counted('lastPrice', {
|
|
||||||
instrumentUid: uid,
|
|
||||||
price: { units: '100' },
|
|
||||||
time: { seconds: Date.UTC(2026, 7, 31) / 1000 },
|
|
||||||
}),
|
|
||||||
getLastPrices: (_token: string, uids: string[]) =>
|
getLastPrices: (_token: string, uids: string[]) =>
|
||||||
counted(
|
counted(
|
||||||
'lastPrices',
|
'lastPrices',
|
||||||
|
|||||||
@@ -103,10 +103,11 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
release([]);
|
release([]);
|
||||||
const done = await settled(ids[0]);
|
const done = await settled(ids[0]);
|
||||||
expect(done.state).toBe('succeeded');
|
expect(done.state).toBe('succeeded');
|
||||||
expect(done.resources[0]).toMatchObject({
|
const accounts = await request(app.getHttpServer())
|
||||||
resourceKey: 'accounts',
|
.get('/api/v1/brokerage-accounts?sync=observe')
|
||||||
availability: 'complete',
|
.set('Authorization', auth())
|
||||||
});
|
.expect(200);
|
||||||
|
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
|
||||||
expect(JSON.stringify(done)).not.toMatch(
|
expect(JSON.stringify(done)).not.toMatch(
|
||||||
/revision|attemptId|encryptedToken|synthetic/,
|
/revision|attemptId|encryptedToken|synthetic/,
|
||||||
);
|
);
|
||||||
@@ -297,20 +298,17 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
.set('Authorization', auth())
|
.set('Authorization', auth())
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const first = before.body as {
|
const first = before.body as {
|
||||||
meta: { dataRevision: string; stateVersion: string };
|
meta: { dataRevision: string };
|
||||||
};
|
};
|
||||||
const last = after.body as {
|
const last = after.body as {
|
||||||
data: { items: unknown[] };
|
data: { items: unknown[] };
|
||||||
meta: {
|
meta: {
|
||||||
dataRevision: string;
|
dataRevision: string;
|
||||||
stateVersion: string;
|
|
||||||
sync: { id: string };
|
sync: { id: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
expect(last.data.items).toEqual([]);
|
expect(last.data.items).toEqual([]);
|
||||||
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||||
expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion);
|
|
||||||
expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion);
|
|
||||||
await status(last.meta.sync.id).expect(200);
|
await status(last.meta.sync.id).expect(200);
|
||||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -389,7 +387,6 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
try {
|
try {
|
||||||
expect((await status(second).expect(200)).body).toMatchObject({
|
expect((await status(second).expect(200)).body).toMatchObject({
|
||||||
state: 'running',
|
state: 'running',
|
||||||
progress: { completed: 1, total: 2, discovering: false },
|
|
||||||
});
|
});
|
||||||
await status(second, 'other').expect(404);
|
await status(second, 'other').expect(404);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -401,7 +398,6 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
expect(await settled(second)).toMatchObject({
|
expect(await settled(second)).toMatchObject({
|
||||||
state: outcome,
|
state: outcome,
|
||||||
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
||||||
progress: { completed: 2, total: 2, discovering: false },
|
|
||||||
});
|
});
|
||||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||||
await status(second).expect(404);
|
await status(second).expect(404);
|
||||||
|
|||||||
Reference in New Issue
Block a user