diff --git a/.ai/architecture.md b/.ai/architecture.md index 8776372..a0440a2 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json` для публичных consumers. -Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет -request context и ограничивает поля событий. Jest unit tests, изолированные +Problem Details фильтруются общей HTTP boundary; native Pino добавляет request +context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения. Локальные источники выбираются через [Источники](../AGENTS.md#источники). diff --git a/.ai/conventions/integrations.md b/.ai/conventions/integrations.md index 9b5f4d5..f824fe9 100644 --- a/.ai/conventions/integrations.md +++ b/.ai/conventions/integrations.md @@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин [validated environment](../../src/config/env.validation.ts) и [TbankClient](../../src/integrations/tbank/tbank.client.ts). +Все RPC, включая metadata, допускают один повтор временной upstream-ошибки. +Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому +диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются. + Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют собственный bounded timeout и безопасный unavailable outcome; не приписывай им gRPC retry policy. При изменении upstream сначала прочитай конкретный client. diff --git a/.ai/conventions/observability.md b/.ai/conventions/observability.md index 66d56b9..ed6fe44 100644 --- a/.ai/conventions/observability.md +++ b/.ai/conventions/observability.md @@ -7,10 +7,12 @@ - `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC; `warn` — восстановимая проблема/retry; `error` — окончательный сбой. Не добавляй log на каждый вход/выход функции или дубликат HTTP completion. -- RequestContextService и request middleware владеют correlation: серверный - request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно. +- Native `nestjs-pino` context и `pino-http` completion владеют correlation: + server request ID возвращается как `X-Request-ID`, client ID хранится отдельно. + Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage + или HTTP completion middleware не нужен. Передавай существующий context, не создавай собственный UUID в каждом service. -- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords, +- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords, tokens, cookie, Authorization, credentials, connection generation, raw body или upstream error. Secret в `message/stack` не становится безопасным из-за allowlist; правила [Security](security-auth.md) обязательны для sensitive data. diff --git a/.ai/conventions/security-auth.md b/.ai/conventions/security-auth.md index 6c571d0..ce45ede 100644 --- a/.ai/conventions/security-auth.md +++ b/.ai/conventions/security-auth.md @@ -17,7 +17,9 @@ Source code конфигурации cookie — проверки browser/HTTP поведения, не только TypeScript. Refresh rotation атомарна: один победитель, проигравший/superseded token получает -401, но сам по себе не отзывает session победителя. Не вводи replay history/grace +401, но не меняет cookie и сам по себе не отзывает session победителя. +Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login. +Не вводи Web Locks, replay history/grace или иной security contract без текущей задачи. Logout идемпотентен, отзывает сессию и очищает refresh cookie на HTTP-границе. diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 19e68f3..3e214f2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4,11 +4,6 @@ "count": 1 } }, - "src/common/logging/request-logging.middleware.ts": { - "sonarjs/no-nested-conditional": { - "count": 1 - } - }, "src/integrations/tbank/instrument-metadata.repository.ts": { "sonarjs/cognitive-complexity": { "count": 1 @@ -54,7 +49,7 @@ }, "src/modules/investments/instruments/application/candle.service.ts": { "sonarjs/cognitive-complexity": { - "count": 4 + "count": 3 }, "sonarjs/no-nested-conditional": { "count": 4 @@ -169,7 +164,7 @@ }, "src/modules/investments/tbank-sync/tbank-sync.service.ts": { "sonarjs/cognitive-complexity": { - "count": 3 + "count": 1 }, "sonarjs/no-nested-conditional": { "count": 7 @@ -203,4 +198,4 @@ "count": 3 } } -} \ No newline at end of file +} diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index 2a6c2b3..8cce392 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -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. 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') .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', diff --git a/src/app.module.ts b/src/app.module.ts index 02a572d..9b9bf6f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { LoggerModule } from 'nestjs-pino'; -import { randomUUID } from 'node:crypto'; +import { ConfigModule } from '@nestjs/config'; 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 { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; @@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module'; isGlobal: true, validate: validateEnv, }), - LoggerModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - 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, PrismaModule, AuthModule, diff --git a/src/common/logging/logging.module.ts b/src/common/logging/logging.module.ts index 510d88e..5d115e8 100644 --- a/src/common/logging/logging.module.ts +++ b/src/common/logging/logging.module.ts @@ -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 { RequestLoggingMiddleware } from './request-logging.middleware'; +import { Global, Module } from '@nestjs/common'; +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'; +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}` + : ''; + 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): Options { + 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() @Module({ - providers: [ - RequestContextService, - RequestLoggingMiddleware, - StructuredLogger, - ], - exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger], + imports: [LoggerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ pinoHttp: loggingOptions(config), assignResponse: true }), + })], + providers: [StructuredLogger], + exports: [StructuredLogger], }) export class LoggingModule {} diff --git a/src/common/logging/request-context.service.ts b/src/common/logging/request-context.service.ts deleted file mode 100644 index 828adc9..0000000 --- a/src/common/logging/request-context.service.ts +++ /dev/null @@ -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(); - - run(context: RequestLogContext, callback: () => T): T { - return this.storage.run(context, callback); - } - - get(): RequestLogContext | undefined { - return this.storage.getStore(); - } - - set(context: Partial): void { - const current = this.storage.getStore(); - if (current) Object.assign(current, context); - } -} diff --git a/src/common/logging/request-logging.middleware.spec.ts b/src/common/logging/request-logging.middleware.spec.ts deleted file mode 100644 index 780ab34..0000000 --- a/src/common/logging/request-logging.middleware.spec.ts +++ /dev/null @@ -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; - 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 }), - ); - }); -}); diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts deleted file mode 100644 index a942167..0000000 --- a/src/common/logging/request-logging.middleware.ts +++ /dev/null @@ -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}` - : ''; - 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, - ); - } -} diff --git a/src/common/logging/structured-logger.service.spec.ts b/src/common/logging/structured-logger.service.spec.ts index 3dd3a2e..11ffad3 100644 --- a/src/common/logging/structured-logger.service.spec.ts +++ b/src/common/logging/structured-logger.service.spec.ts @@ -1,7 +1,6 @@ import { StructuredLogger } from './structured-logger.service'; describe('StructuredLogger', () => { - const context = { get: jest.fn() }; const pino = { debug: jest.fn(), info: jest.fn(), @@ -12,8 +11,7 @@ describe('StructuredLogger', () => { beforeEach(() => jest.clearAllMocks()); it('passes only allowlisted fields to Pino', () => { - context.get.mockReturnValue({ requestId: 'request', token: 'secret' }); - const logger = new StructuredLogger(context as never, pino as never); + const logger = new StructuredLogger(pino as never); logger.info('event', { userId: 'user', @@ -23,7 +21,6 @@ describe('StructuredLogger', () => { expect(pino.info).toHaveBeenCalledWith( { - requestId: 'request', userId: 'user', brokerageAccountId: 'account', }, @@ -32,8 +29,7 @@ describe('StructuredLogger', () => { }); it('delegates event levels to Pino', () => { - context.get.mockReturnValue(undefined); - const logger = new StructuredLogger(context as never, pino as never); + const logger = new StructuredLogger(pino as never); logger.event('debug', 'debug event'); logger.event('warn', 'warn event'); diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 8e2fb71..a9ddb8a 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -1,8 +1,6 @@ import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { PinoLogger } from 'nestjs-pino'; -import { RequestContextService } from './request-context.service'; - type LogLevel = 'debug' | 'error' | 'info' | 'warn'; type LogContext = Record; @@ -27,10 +25,7 @@ const allowedFields = new Set([ @Injectable() export class StructuredLogger implements OnApplicationShutdown { - constructor( - private readonly requestContext: RequestContextService, - private readonly pino: PinoLogger, - ) {} + constructor(private readonly pino: PinoLogger) {} info(message: string, context?: LogContext): void { this.write('info', message, context); @@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown { ): void { // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. this.pino[level]( - this.safeContext({ ...this.requestContext.get(), ...context }), + this.safeContext(context), message, ); } diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 56b6fb6..a31f9b6 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => { status.DEADLINE_EXCEEDED, 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) => { requestRpc - .mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code }) .mockResolvedValueOnce({ instrument }); const result = client.getInstrumentByUid('token', 'uid'); - await jest.advanceTimersByTimeAsync(1999); + await jest.advanceTimersByTimeAsync(999); expect(requestRpc).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(1); expect(requestRpc).toHaveBeenCalledTimes(2); - await jest.advanceTimersByTimeAsync(3999); - expect(requestRpc).toHaveBeenCalledTimes(2); - await jest.advanceTimersByTimeAsync(1); - await expect(result).resolves.toEqual(instrument); - expect(queue.run).toHaveBeenCalledTimes(3); + expect(queue.run).toHaveBeenCalledTimes(2); 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; requestRpc.mockResolvedValue({ instrument }); - const result = client.getInstrumentByFigi('token', 'figi'); - await jest.advanceTimersByTimeAsync(1999); + await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException); expect(queue.run).not.toHaveBeenCalled(); - queue.size = 49; - 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'); + expect(jest.getTimerCount()).toBe(0); }); it('does not bypass queue admission after an upstream failure', async () => { @@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => { .mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockResolvedValueOnce({ instrument }); - const result = client.getInstrumentByUid('token', 'uid'); + const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error); queue.size = 50; - await jest.advanceTimersByTimeAsync(2000); + await jest.advanceTimersByTimeAsync(1000); expect(queue.run).toHaveBeenCalledTimes(1); - queue.size = 0; - 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(await result).toBeInstanceOf(ServiceUnavailableException); 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 }); await Promise.all([ 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); }); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index a3ff196..0edaa43 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -46,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0; const NEXT_DAY_OFFSET = 1; const RATE_WINDOW_MS = 1_000; -const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; const MAX_QUEUE_SIZE = 50; -const METADATA_RETRY_DELAY_MS = 2_000; const RPC_RETRY_DELAY_MS = 1_000; const requestDeadlineMs = 15_000; @@ -419,12 +417,6 @@ export class TbankClient { ).then((response) => response.lastPrices ?? []); } - getLastPrice(token: string, uid: string): Promise { - return this.getLastPrices(token, [uid]).then( - (prices) => prices.find((item) => item.instrumentUid === uid) ?? null, - ); - } - getTradingStatus(token: string, uid: string): Promise { return this.call<{ tradingStatus?: string }>( token, @@ -486,8 +478,7 @@ export class TbankClient { request: object, ): Promise { const metadataCall = method === 'getInstrumentBy'; - const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; - for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) { + for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { @@ -508,34 +499,25 @@ export class TbankClient { const failureContext = queueFull ? { code: TbankErrorCode.Unavailable } : { grpcCode }; - const retryable = queueFull - ? metadataCall - : [ - status.DEADLINE_EXCEEDED, - status.RESOURCE_EXHAUSTED, - status.UNAVAILABLE, - ].includes(grpcCode); - if (retryable && attempts < maxAttempts) { + const retryable = !queueFull && [ + status.DEADLINE_EXCEEDED, + status.RESOURCE_EXHAUSTED, + status.UNAVAILABLE, + ].includes(grpcCode); + if (retryable && attempts < RPC_MAX_ATTEMPTS) { this.logger.event('warn', 'tbank.rpc.retry', { operation: method, ...failureContext, attempts, durationMs: Math.round(performance.now() - startedAt), }); - await new Promise((resolve) => - setTimeout( - resolve, - metadataCall - ? attempts * METADATA_RETRY_DELAY_MS - : RPC_RETRY_DELAY_MS, - ), - ); + await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS)); continue; } this.logger.event('error', 'tbank.rpc.failed', { operation: method, ...failureContext, - attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts, + attempts: queueFull ? NO_RPC_ATTEMPTS : attempts, durationMs: Math.round(performance.now() - startedAt), }); if ( diff --git a/src/main.ts b/src/main.ts index 1ea1611..b96d24c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino'; import { AppModule } from './app.module'; 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 { Environment } from './config/env.validation'; import { shouldExposeSwagger } from './config/swagger.config'; @@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void { }); app.setGlobalPrefix(API_PREFIX); app.use(cookieParser()); - const requestLogging = app.get(RequestLoggingMiddleware); - app.use(requestLogging.use.bind(requestLogging)); app.useGlobalPipes( new ValidationPipe({ forbidUnknownValues: true, diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index a121898..cf50136 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -85,14 +85,9 @@ export class AuthController { @Req() request: Request, @Res({ passthrough: true }) response: Response, ) { - try { - const token = this.refreshToken(request); - if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); - return this.respond(response, await this.auth.refresh(token)); - } catch (error) { - if (error instanceof UnauthorizedException) this.clearCookie(response); - throw error; - } + const token = this.refreshToken(request); + if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); + return this.respond(response, await this.auth.refresh(token)); } @Post('logout') diff --git a/src/modules/auth/presentation/jwt-auth.guard.ts b/src/modules/auth/presentation/jwt-auth.guard.ts index 7056b18..e7e0d8e 100644 --- a/src/modules/auth/presentation/jwt-auth.guard.ts +++ b/src/modules/auth/presentation/jwt-auth.guard.ts @@ -1,12 +1,12 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { PinoLogger } from 'nestjs-pino'; -import { RequestContextService } from '../../../common/logging/request-context.service'; import { AccessPayload } from '../application/auth.types'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { - constructor(private readonly requestContext: RequestContextService) { + constructor(private readonly logger: PinoLogger) { super(); } @@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { .switchToHttp() .getRequest<{ user?: AccessPayload }>(); if (request.user?.sub) { - this.requestContext.set({ userId: request.user.sub }); + this.logger.assign({ userId: request.user.sub }); } return Boolean(allowed); } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 95a29ad..4f33ba9 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -49,7 +49,7 @@ export class BankCardsRepository { isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || (error instanceof Error && /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; } if (busy) conflict(BankErrorCode.WriteConflict); diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts deleted file mode 100644 index c50abcf..0000000 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts +++ /dev/null @@ -1 +0,0 @@ -export { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/income/income-read.api.ts b/src/modules/investments/income/income-read.api.ts deleted file mode 100644 index e820d2f..0000000 --- a/src/modules/investments/income/income-read.api.ts +++ /dev/null @@ -1 +0,0 @@ -export { IncomeAnalyticsService } from './income-analytics.service'; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 200d416..5b7194c 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -15,7 +15,6 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { planInvestmentScreen } from '../../investment-sync-plan'; import { candleGaps, type CandleRange, @@ -28,8 +27,6 @@ import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; -const FIRST_BATCH_OFFSET = 0; -const BACKGROUND_SYNC_PRIORITY = 2; const REPLACED_WINDOW_COUNT = 1; const NEXT_WINDOW_OFFSET = 1; @@ -55,7 +52,6 @@ type Producer = { }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; -const prefetchReadBatchSize = 100; function scope(input: CandleInput) { 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 reservations: Promise = Promise.resolve(); private running = false; - private readonly prefetches = new Map>(); constructor( private readonly repository: CandleRepository, 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> = - []; - const coverage: Awaited> = []; - 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( userId: string, revision: string, @@ -499,17 +390,6 @@ export class CandleService { this.reservations = finished.catch(() => undefined); if (!(await finished)) { 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 { diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index b96b7d9..451e2d7 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -12,6 +12,7 @@ import { TbankAccessService, type TbankBond, type TbankCoupon, + tbankSourceNeedsRefresh, } from '../../../../../integrations/tbank/tbank.api'; import { ForecastService, @@ -122,6 +123,10 @@ export class BondIncomeService { candidate: 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 ? result : { diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index 844916f..d01ec5b 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -53,12 +53,6 @@ export class CandleRepository { }); } - coverage(uids: string[]) { - return this.prisma.tbankCandleCoverage.findMany({ - where: { provider, instrumentUid: { in: uids }, interval }, - }); - } - reserve( owner: { revision: string; userId: string }, scopeKey: string, diff --git a/src/modules/investments/instruments/instrument-income.api.ts b/src/modules/investments/instruments/instrument-income.api.ts deleted file mode 100644 index 8b01151..0000000 --- a/src/modules/investments/instruments/instrument-income.api.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { BondIncomeService } from './bonds/application/bond-income.service'; -export { StockIncomeService } from './stocks/application/stock-income.service'; diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index cdb47c4..42eedb1 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, + tbankSourceNeedsRefresh, } from '../../../../../integrations/tbank/tbank.api'; import { ForecastService, @@ -91,6 +92,10 @@ export class StockIncomeService { candidate: 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 ? result : { diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index 32ab6e8..5d3eca3 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator'; export const availabilityValues = ['missing', 'partial', 'complete'] 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 { @ApiPropertyOptional({ enum: ['observe'] }) @@ -61,9 +61,6 @@ export class InvestmentDependencyDto { @ApiProperty() declare dataRevision: string; - @ApiProperty() - declare stateVersion: string; - @ApiPropertyOptional({ type: String }) unavailableReason?: string; diff --git a/src/modules/investments/investment-resource.mapper.spec.ts b/src/modules/investments/investment-resource.mapper.spec.ts index 2fb772e..6ef5727 100644 --- a/src/modules/investments/investment-resource.mapper.spec.ts +++ b/src/modules/investments/investment-resource.mapper.spec.ts @@ -29,7 +29,6 @@ describe('investment resource source freshness', () => { const stale = investmentResourceMeta(input); expect(stale.freshness).toBe('stale'); expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt); - expect(stale.stateVersion).not.toBe(fresh.stateVersion); expect( investmentResourceMeta({ ...input, @@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => { }); expect(terminal.sync).toEqual(failed.sync); expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt); - expect(terminal.stateVersion).not.toBe(parent.stateVersion); expect( investmentResourceMeta({ ...input, diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index e072acf..ab2e8eb 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; - import { InvestmentResourceMetaDto } from './investment-resource.dto'; export function investmentResourceMeta(input: { @@ -31,7 +29,6 @@ export function investmentResourceMeta(input: { ]; const sync = states.find((state) => state.state === 'running') ?? - states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; const sourceFreshness = input.fetchedAt @@ -71,19 +68,5 @@ export function investmentResourceMeta(input: { ...(input.coverage ? { coverage: input.coverage } : {}), ...(input.dependencies ? { dependencies: input.dependencies } : {}), }; - return { - ...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'), - }; + return meta; } diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index c907c1c..9d04c51 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -267,25 +267,6 @@ export function planInvestmentScreen( 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)) { sources.push( diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index f896501..d88d689 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; -import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { ForecastRepository, @@ -10,7 +9,6 @@ import { } from '../infrastructure/forecast.repository'; const MISSING_DATA_REVISION = 0; -const STALE_FRESHNESS_MS = 0; @Injectable() export class ForecastService { @@ -73,23 +71,8 @@ export class ForecastService { dependencies .map((dependency) => dependency.sync) .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 { - data, + data: stored ? (stored.payload as T) : null, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), @@ -101,10 +84,7 @@ export class ForecastService { ? 'partial' : 'missing', fetchedAt: first ? new Date(first) : null, - freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true - ? STALE_FRESHNESS_MS - : Number.POSITIVE_INFINITY, + freshForMs: Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index f29cf3a..5788983 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,4 +1,3 @@ -import { ServiceUnavailableException } from '@nestjs/common'; import { setImmediate as immediate } from 'node:timers/promises'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {}, @@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => { service = new PortfolioService( access as never, tbank as never, - { set: jest.fn() } as never, metadata as never, repository as never, { request: jest.fn().mockResolvedValue(null) } as never, { event: jest.fn() } as never, - { prefetch: jest.fn().mockResolvedValue(undefined) } as never, ); access.local.mockResolvedValue({ revision: 'private', @@ -221,15 +218,4 @@ describe('persistent portfolio projection', () => { expect(complete.data?.isStale).toBe(true); 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, - ); - }); }); diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 14d7f19..6a6fdf8 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,11 +1,9 @@ import { Injectable, NotFoundException, - ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { RequestContextService } from '../../../../common/logging/request-context.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { @@ -19,7 +17,6 @@ import { type TbankPortfolio, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { CandleService } from '../../instruments/instruments.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { portfolioStructure } from '../domain/portfolio-structure'; import { sumMoneyByCurrency } from '../domain/portfolio-totals'; @@ -38,23 +35,19 @@ const membershipFreshForMs = tbankSourceTtlMs.membership; @Injectable() export class PortfolioService { private readonly processRunId = randomUUID(); - private readonly active = new Set(); private readonly pending = new Map>(); private reservations: Promise = Promise.resolve(); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, - private readonly requestContext: RequestContextService, private readonly metadata: InstrumentMetadataService, private readonly repository: PortfolioRepository, private readonly membership: TbankMembershipService, private readonly logger: StructuredLogger, - private readonly candles: CandleService, ) {} async saved(userId: string, accountId: string, observe = false) { - this.requestContext.set({ userId, brokerageAccountId: accountId }); const connection = await this.access.local(userId, accountId); if (!observe) { 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({ resourceKey: 'accounts/membership', dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), @@ -319,7 +290,7 @@ export class PortfolioService { revision, accountId, this.processRunId, - this.active, + this.pending, force, ); if (!requested) return null; @@ -330,7 +301,6 @@ export class PortfolioService { }; } const { id, attemptId } = requested.work; - this.active.add(attemptId); const done = new Promise((resolve) => setImmediate(resolve)) .then(async () => { const attempt = { id, attemptId, userId, revision }; @@ -354,13 +324,6 @@ export class PortfolioService { for (const position of snapshot.positions) { await this.metadata.request(userId, revision, position); } - if (!force) { - await this.candles.prefetch( - userId, - revision, - snapshot.positions, - ); - } } } catch (error) { if ( @@ -377,7 +340,6 @@ export class PortfolioService { }), ) .finally(() => { - this.active.delete(attemptId); this.pending.delete(attemptId); }); this.pending.set(attemptId, done); @@ -387,18 +349,6 @@ export class PortfolioService { return reserved; } - async get(userId: string, accountId: string): Promise { - const result = await this.read(userId, accountId); - if (!result.data) { - throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); - } - return result.data; - } - - getAll(userId: string): Promise { - return this.get(userId, 'all'); - } - private async normalize( upstream: NonNullable, money: TbankMoney[], diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index d7987db..e888736 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -44,15 +44,9 @@ export class ForecastRepository { data: { membershipRevision: { increment: 0 } }, }); if (!current.count) return null; - } - const connection = await tx.tbankConnection.findFirst({ - where: { - ...owner, - membershipRevision: input.membershipRevision, - invalidToken: false, - }, - }); - if (!connection) return null; + } else if (!(await tx.tbankConnection.findFirst({ + where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false }, + }))) return null; const key = { ...owner, scopeKey: input.scopeKey }; const previous = await tx.tbankForecast.findUnique({ where: { userId_revision_scopeKey: key }, diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 682e9de..a2dc46d 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -52,7 +52,7 @@ export class PortfolioRepository { revision: string, accountId: string, processRunId: string, - active: ReadonlySet, + pending: ReadonlyMap>, force: boolean, ) { return this.prisma.$transaction(async (tx) => { @@ -83,7 +83,7 @@ export class PortfolioRepository { if ( previous?.state === 'running' && previous.processRunId === processRunId && - active.has(previous.attemptId) + pending.has(previous.attemptId) ) { return { work: previous, launch: false }; } diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 6b462b2..307456c 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { ForecastService } from './application/forecast.service'; import { PortfolioService } from './application/portfolio.service'; import { ForecastRepository } from './infrastructure/forecast.repository'; @@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { PortfolioController } from './presentation/portfolio.controller'; @Module({ - imports: [AuthModule, TbankModule, InstrumentSourcesModule], + imports: [AuthModule, TbankModule], controllers: [PortfolioController], providers: [ PortfolioService, diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index f7341ac..a84d3d0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -22,7 +22,6 @@ import { } from 'class-validator'; import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; -import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; const MIN_ACCOUNT_ID_LENGTH = 1; @@ -216,11 +215,6 @@ export class TbankSyncAcceptedDto { @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 { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) @@ -232,10 +226,4 @@ export class TbankSyncStatusDto { @ApiProperty({ type: String, nullable: true }) declare errorCode: null | string; - - @ApiProperty({ type: TbankSyncProgressDto }) - declare progress: TbankSyncProgressDto; - - @ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) - declare resources: InvestmentResourceMetaDto[]; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index 32e5d3d..18198d4 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -2,13 +2,9 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.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 { InstrumentsModule } from '../instruments/instruments.module'; import { OperationsModule } from '../operations/operations.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; -import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankSyncController } from './tbank-sync.controller'; import { TbankSyncRepository } from './tbank-sync.repository'; import { TbankSyncService } from './tbank-sync.service'; @@ -19,10 +15,6 @@ import { TbankSyncService } from './tbank-sync.service'; PortfolioModule, OperationsModule, InstrumentSourcesModule, - InstrumentsModule, - BrokerageAccountsModule, - IncomeModule, - UpcomingEventsModule, ], controllers: [TbankSyncController], providers: [TbankSyncService, TbankSyncRepository], diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 4fe800a..1d70fd8 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -1,5 +1,5 @@ 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 { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; @@ -27,7 +27,7 @@ export class TbankSyncRepository { data: { membershipRevision: { increment: 0 } }, }); 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 previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: key }, diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 96f0682..d1390b1 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -6,8 +6,6 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; - import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { @@ -16,26 +14,17 @@ import { TbankMembershipService, type TbankSyncAttempt, } 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 { CandleService, CatalogDiscoveryService, CatalogEnrichmentService, } from '../instruments/instruments.api'; -import { investmentResourceMeta } from '../investment-resource.mapper'; import { planInvestmentScreen } from '../investment-sync-plan'; import { - BrokerageOperationsService, BrokerageOperationsSyncService, type OperationInterval, } from '../operations/operations.api'; import { PortfolioService } from '../portfolio/portfolio.api'; -import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; import { normalizeScreen } from './tbank-screen'; import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; import { @@ -44,9 +33,6 @@ import { } from './tbank-sync.repository'; const FOREGROUND_CANDLE_PRIORITY = 0; -const EXCLUSIVE_END_OFFSET_MS = 1; -const PENDING_CHILD_COUNT = 0; -const COMPLETED_CHILD_COUNT = 1; @Injectable() export class TbankSyncService { @@ -59,16 +45,10 @@ export class TbankSyncService { private readonly membership: TbankMembershipService, private readonly portfolio: PortfolioService, private readonly operations: BrokerageOperationsSyncService, - private readonly ledger: BrokerageOperationsService, private readonly metadata: InstrumentMetadataService, private readonly discovery: CatalogDiscoveryService, private readonly sources: CatalogEnrichmentService, 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) { @@ -368,7 +348,6 @@ export class TbankSyncService { : children.some((child) => child.state === 'cancelled') ? 'cancelled' : 'succeeded'; - const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== connection.revision @@ -380,12 +359,6 @@ export class TbankSyncService { ...intent, state, 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, work: NonNullable>>['work'], ): Promise { - const resources: InvestmentResourceMetaDto[] = []; - const input = work.intent as Record; - 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:') ? await this.operations.observe(userId, work.id) : work; @@ -515,112 +388,6 @@ export class TbankSyncService { now: work.createdAt.toISOString(), state, 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; - } } diff --git a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts index 5226bbe..c250038 100644 --- a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts +++ b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts @@ -1,12 +1,13 @@ +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; +import type { TbankDividend } from '../../../integrations/tbank/tbank.api'; + import { decimalFromString, formatDecimal, multiply, } from '../../../common/decimal'; 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 type { TbankDividend } from '../../../integrations/tbank/tbank.api'; import { UpcomingEventDto } from './upcoming-events.dto'; export function dividendEvent( diff --git a/src/modules/investments/upcoming-events/upcoming-events-read.api.ts b/src/modules/investments/upcoming-events/upcoming-events-read.api.ts deleted file mode 100644 index 50d6466..0000000 --- a/src/modules/investments/upcoming-events/upcoming-events-read.api.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index f03fb38..243f93b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -20,12 +20,12 @@ import { type PortfolioPositionDto, PortfolioService, } from '../portfolio/portfolio.api'; +import { bondEvent, dividendEvent } from './upcoming-event.mapper'; import { UpcomingEventDto, UpcomingEventsDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; -import { bondEvent, dividendEvent } from './upcoming-event.mapper'; enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } @@ -226,7 +226,7 @@ function dateWindow(query: UpcomingEventsQueryDto) { } function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { - const totals = new Map>(); + const totals = new Map>(); for (const event of events) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index f73ba25..0193285 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,12 +1,13 @@ import { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { PinoLogger } from 'nestjs-pino'; +import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; 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 { AuthService } from '../src/modules/auth/application/auth.service'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -16,10 +17,22 @@ type Problem = { code: string }; describe('Authentication (e2e)', () => { let app: INestApplication; + const logs: Record[] = []; beforeEach(async () => { + logs.length = 0; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], + }).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({ + inject: [ConfigService], + factory: (config: Parameters[0]) => ({ + pinoHttp: { + ...loggingOptions(config), + level: 'info', + stream: { write: (line: string) => logs.push(JSON.parse(line) as Record) }, + }, + assignResponse: true, + }), }).compile(); app = moduleFixture.createNestApplication(); @@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => { ]); const winner = responses.find((response) => response.status === 200); if (!winner) throw new Error('Expected one successful rotation'); + expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined(); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', cookie) - .expect(401); + .expect(401) + .expect((response) => expect(response.headers['set-cookie']).toBeUndefined()); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .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 () => { - const logger = app.get(StructuredLogger); - const event = jest.spyOn(logger, 'event'); const response = await request(app.getHttpServer()) .get('/api/v1/auth/me') .set('Origin', 'http://localhost:5173') @@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => { expect(response.headers['access-control-expose-headers']).toContain( 'X-Request-ID', ); - expect(event).toHaveBeenCalledTimes(1); - expect(event).toHaveBeenCalledWith( - 'warn', - 'http.request.completed', - expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }), - ); + expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([ + expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }), + ]); }); 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'; jest .spyOn(app.get(AuthService), 'login') @@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => { expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), ); - expect(error).toHaveBeenCalledTimes(1); - expect(error).toHaveBeenCalledWith( + const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']); + expect(completed).toEqual([ expect.objectContaining({ requestId: response.headers['x-request-id'], clientRequestId, status: 500, code: 'INTERNAL_SERVER_ERROR', + level: 50, + message: 'http.request.completed', }), - 'http.request.completed', - ); - expect(JSON.stringify(error.mock.calls)).not.toContain( + ]); + expect(JSON.stringify(logs)).not.toContain( 'SYNTHETIC_AUDIT_SECRET', ); }); diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index cbdfd51..c4fc5c7 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -41,7 +41,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => { lastSuccessfulAt: null, resourceKey: 'detail/uid', dataRevision: '1', - stateVersion: 'detail-v1', sync: { id: null, state: 'idle', errorCode: null }, }, }, @@ -273,7 +272,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ).toBe(abandoned?.work.attemptId); await reopened.read('owner', input); await settled(); - expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1); + expect(upstream.getCandles).toHaveBeenCalledTimes(1); expect( (await repository.work('owner', 'revision', 'candles:uid:month')) ?.attemptId, @@ -483,52 +482,20 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }), ).toBe(1); }); - it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => { - await prisma.tbankInstrument.create({ - data: { - provider: 'tbank', - uid: 'opened', - instrumentType: 'share', - metadata: {}, - fetchedAt: new Date(), - }, - }); + it('loads only the requested period and reuses its saved coverage', async () => { const service = createService(); - let release: (value: unknown[]) => void = () => {}; - const blocked = new Promise((resolve) => { - 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', + const input = { + uid: 'uid', instrumentType: 'share', period: 'month', - }); - release([]); + } as const; + await service.read('owner', input); await settled(); - expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe( - 'opened', - ); - expect( - new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), - ).toEqual(new Set(['opened', 'uid'])); + expect(upstream.getCandles).toHaveBeenCalledTimes(1); + expect(await repository.work('owner', 'revision', 'candles:uid:all')).toBeNull(); const count = upstream.getCandles.mock.calls.length; - await service.prefetch('owner', 'revision', [ - { instrumentUid: 'uid', type: 'STOCK' }, - ]); + await service.read('owner', input); + await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(count); - expect( - (await repository.work('owner', 'revision', 'candles:opened:all'))?.state, - ).toBe('complete'); }); }); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 9f9d699..e87b3f5 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -263,7 +263,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { 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 = () => request(app.getHttpServer()) .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( first.meta.coverage?.requested, ); - expect(second.meta.stateVersion).toBe(first.meta.stateVersion); + expect(second.meta.dataRevision).toBe(first.meta.dataRevision); await prisma.tbankOperationCoverage.updateMany({ data: { to: new Date(Date.now() - 60_000), @@ -285,10 +285,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }); const changed = (await observe().expect(200)) .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(); }); - 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)) .body as BrokerageOperationsResourceDto; 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)) .body as BrokerageOperationsResourceDto; 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); await get( `limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`, diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index b3388b4..39baa32 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -134,12 +134,6 @@ describe('T-Bank persisted read budget', () => { 'bond', 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[]) => counted( 'lastPrices', diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index bf3c590..68695da 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -103,10 +103,11 @@ describe('screen sync HTTP and SQLite', () => { release([]); const done = await settled(ids[0]); expect(done.state).toBe('succeeded'); - expect(done.resources[0]).toMatchObject({ - resourceKey: 'accounts', - availability: 'complete', - }); + const accounts = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts?sync=observe') + .set('Authorization', auth()) + .expect(200); + expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } }); expect(JSON.stringify(done)).not.toMatch( /revision|attemptId|encryptedToken|synthetic/, ); @@ -297,20 +298,17 @@ describe('screen sync HTTP and SQLite', () => { .set('Authorization', auth()) .expect(200); const first = before.body as { - meta: { dataRevision: string; stateVersion: string }; + meta: { dataRevision: string }; }; const last = after.body as { data: { items: unknown[] }; meta: { dataRevision: string; - stateVersion: string; sync: { id: string }; }; }; expect(last.data.items).toEqual([]); 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); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); }); @@ -389,7 +387,6 @@ describe('screen sync HTTP and SQLite', () => { try { expect((await status(second).expect(200)).body).toMatchObject({ state: 'running', - progress: { completed: 1, total: 2, discovering: false }, }); await status(second, 'other').expect(404); } finally { @@ -401,7 +398,6 @@ describe('screen sync HTTP and SQLite', () => { expect(await settled(second)).toMatchObject({ state: outcome, errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null, - progress: { completed: 2, total: 2, discovering: false }, }); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); await status(second).expect(404);