refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
Backend verify / commits (pull_request) Successful in 1m25s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Successful in 6m20s

This commit is contained in:
2026-11-05 22:22:48 +03:00
parent 83b515a453
commit d1c5f575cf
36 changed files with 182 additions and 832 deletions
@@ -13,12 +13,10 @@ import {
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import {
CatalogDiscoveryRepository,
catalogQuery,
type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
catalogSearchLimit,
selectCatalogInstruments,
@@ -26,7 +24,6 @@ import {
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2;
@@ -34,203 +31,56 @@ const tickerAmbiguityLimit = 2;
@Injectable()
export class CatalogDiscoveryService {
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly enrichment: CatalogEnrichmentService,
) {}
async read(userId: string, input: CatalogQuery) {
input = catalogQuery(input);
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input);
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.completeness === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
async search(userId: string, input: CatalogQuery) {
return this.onlineSearch(userId, catalogQuery(input));
}
async detail(
userId: string,
instrumentType: 'bond' | 'share',
uid: string,
observe = false,
) {
if (!observe) {
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
const sources = await this.enrichment.readMany(
userId,
instrumentType,
[uid],
observe,
['detail'],
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
const source = sources[0];
if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(userId, {
instrumentType,
query: instrument.ticker,
exactTicker: true,
});
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
if (!instrument) throw new NotFoundException();
return {
data: instrument ? { ...instrument, uid, slug } : null,
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
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,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
work: null,
}),
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker(
userId: string,
instrumentType: 'bond' | 'share',
ticker: string,
observe = false,
) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read(userId, {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
query: ticker,
exactTicker: true,
});
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
@@ -238,34 +88,8 @@ export class CatalogDiscoveryService {
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
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.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
private async onlineSearch(userId: string, input: CatalogQuery) {