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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user