refactor!: simplify runtime coordination #7 #43

Merged
ksv741 merged 2 commits from codex/simplify-complexity into develop 2026-10-03 21:32:26 +03:00
50 changed files with 235 additions and 929 deletions
+2 -2
View File
@@ -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#источники).
+4
View File
@@ -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.
+5 -3
View File
@@ -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.
+3 -1
View File
@@ -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-границе.
+2 -7
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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,
+59 -9
View File
@@ -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,
); );
} }
+12 -48
View File
@@ -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);
}); });
+9 -27
View File
@@ -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 (
-3
View File
@@ -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
View File
@@ -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',
); );
}); });
+10 -43
View File
@@ -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');
}); });
}); });
+4 -5
View File
@@ -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)}`,
-6
View File
@@ -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',
+6 -10
View File
@@ -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);