refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
This commit is contained in:
+2
-36
@@ -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<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,
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Options } from 'pino-http';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
import { 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}`
|
||||
: '<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()
|
||||
@Module({
|
||||
providers: [
|
||||
RequestContextService,
|
||||
RequestLoggingMiddleware,
|
||||
StructuredLogger,
|
||||
],
|
||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
||||
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||
})],
|
||||
providers: [StructuredLogger],
|
||||
exports: [StructuredLogger],
|
||||
})
|
||||
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';
|
||||
|
||||
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');
|
||||
|
||||
@@ -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<string, boolean | number | string | undefined>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<null | TbankLastPrice> {
|
||||
return this.getLastPrices(token, [uid]).then(
|
||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||
return this.call<{ tradingStatus?: string }>(
|
||||
token,
|
||||
@@ -486,8 +478,7 @@ export class TbankClient {
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
@@ -1 +0,0 @@
|
||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
private running = false;
|
||||
private readonly prefetches = new Map<string, Promise<void>>();
|
||||
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<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(
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
private reservations: Promise<unknown> = 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<void>((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<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(
|
||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||
money: TbankMoney[],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -52,7 +52,7 @@ export class PortfolioRepository {
|
||||
revision: string,
|
||||
accountId: string,
|
||||
processRunId: string,
|
||||
active: ReadonlySet<string>,
|
||||
pending: ReadonlyMap<string, Promise<void>>,
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
||||
): 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:')
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { UpcomingEventsService } from './upcoming-events.service';
|
||||
@@ -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<string, ReturnType<typeof decimal>>();
|
||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user