Files
backend/src/modules/investments/instruments/application/catalog-discovery.service.ts
T
ksv741 d6e6c4ace5
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
feat!: persist T-Bank data and synchronize reads
Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
2026-09-26 21:29:15 +03:00

391 lines
12 KiB
TypeScript

import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
TbankAccessService,
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
catalogQuery,
catalogScope,
type CatalogQuery,
} from '../infrastructure/catalog-discovery.repository';
import {
catalogSearchLimit,
selectCatalogInstruments,
} from './catalog-selection';
const tickerAmbiguityLimit = 2;
type Producer = {
input: CatalogQuery;
attempt: TbankSyncAttempt;
observers: TbankSyncAttempt[];
force: boolean;
};
@Injectable()
export class CatalogDiscoveryService {
private readonly runId = randomUUID();
private readonly active = new Set<string>();
private readonly producers = new Map<string, Producer>();
private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve();
private running = false;
private readonly completions = new Map<
string,
{ workId: string; done: Promise<void>; resolve: () => void }
>();
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
private readonly enrichment: CatalogEnrichmentService,
) {}
async read(userId: string, input: CatalogQuery, observe = false) {
input = catalogQuery(input);
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
if (!observe) await this.request(userId, connection.revision, input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: tbankSourceTtlMs.catalog,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.availability === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
async detail(
userId: string,
instrumentType: 'share' | 'bond',
uid: string,
observe = false,
) {
const sources = await this.enrichment.readMany(
userId,
instrumentType,
[uid],
observe,
['detail'],
);
const source = sources[0];
if (source.meta.availability === 'complete' && !source.instrument)
throw new NotFoundException();
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
) {
const alias = await this.read(
userId,
{ instrumentType, query: instrument.ticker, exactTicker: true },
observe,
);
if (alias.data?.length === 1 && alias.data[0].uid === uid)
slug = instrument.ticker.toUpperCase();
dependencies.push(alias.meta);
}
return {
data: instrument ? { ...instrument, uid, slug } : null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
async byTicker(
userId: string,
instrumentType: 'share' | 'bond',
ticker: string,
observe = false,
) {
const lookup = await this.read(
userId,
{ instrumentType, query: ticker, exactTicker: true },
observe,
);
if (lookup.data?.length === 0) throw new NotFoundException();
if (lookup.data && lookup.data.length > 1)
throw new ConflictException({
code:
instrumentType === 'share'
? 'STOCK_TICKER_AMBIGUOUS'
: 'BOND_TICKER_AMBIGUOUS',
});
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
request(
userId: string,
revision: string,
query: CatalogQuery,
force = false,
) {
const input = catalogQuery(query);
const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input);
if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
)
return null;
const reservation = await this.repository.reserve(
{ userId, revision },
input,
this.runId,
this.active,
);
if (!reservation) return null;
const { id, attemptId } = reservation.work;
if (!reservation.launch) {
const current = this.producers.get(catalogScope(input));
if (current) current.force ||= force;
return id;
}
const attempt = { userId, revision, id, attemptId };
this.active.add(attemptId);
let resolve = () => {};
const done = new Promise<void>((finish) => {
resolve = finish;
});
this.completions.set(attemptId, { workId: id, done, resolve });
const key = catalogScope(input);
const producer = this.producers.get(key);
if (producer) {
producer.observers.push(attempt);
producer.force ||= force;
} else {
const queued = { input, attempt, observers: [], force };
this.producers.set(key, queued);
this.queue.push(queued);
}
if (!this.running) {
this.running = true;
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserved.catch(() => undefined);
return reserved;
}
async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true);
if (id)
await Promise.all(
[...this.completions.values()]
.filter((entry) => entry.workId === id)
.map((entry) => entry.done),
);
const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany(
userId,
query.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
true,
);
return [
...(id ? [id] : []),
...sources.flatMap((source) =>
source.meta.sync.id ? [source.meta.sync.id] : [],
),
];
}
private async drain() {
try {
while (this.queue.length) {
const producer = this.queue.shift();
if (!producer) break;
const { input, attempt } = producer;
let success = false;
try {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker
? found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
)
: found,
input.query,
input.instrumentType,
).slice(
0,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
),
new Date(),
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
await this.access.rejectMembershipToken(attempt);
else
this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE',
});
}
if (success) {
const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers])
if (!input.exactTicker || saved.data?.length === 1)
await this.enrichment.requestMany(
observer.userId,
observer.revision,
input.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
);
}
this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) {
await this.repository.finish(observer, success);
this.active.delete(observer.attemptId);
this.completions.get(observer.attemptId)?.resolve();
this.completions.delete(observer.attemptId);
}
}
} catch {
for (const completion of this.completions.values()) completion.resolve();
this.completions.clear();
this.active.clear();
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
});
} finally {
this.running = false;
}
}
}