feat!: persist T-Bank data and synchronize reads
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

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.
This commit is contained in:
2026-09-26 21:29:15 +03:00
parent 0862d56c4e
commit d6e6c4ace5
116 changed files with 18321 additions and 5409 deletions
@@ -0,0 +1,294 @@
import { accountHistory } from '../../investment-sync-plan';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import {
BrokerageOperationsRepository,
type OperationAttempt,
} from '../infrastructure/brokerage-operations.repository';
import {
operationIntervals,
planOperationRead,
type OperationInterval,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
const operationsPageSize = 1000;
const priorities = { screen: 1, history: 2 } as const;
@Injectable()
export class BrokerageOperationsSyncService {
private readonly processRunId = randomUUID();
private readonly runningAttempts = new Set<string>();
private readonly ready = new Map<
string,
{ attempt: OperationAttempt; priority: number }
>();
private draining = false;
// Only short DB reservations/publications are serialized, never RPCs.
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly repository: BrokerageOperationsRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
) {}
async readPlan(
userId: string,
intervals: OperationInterval[],
options: {
movingEnd?: boolean;
force?: boolean;
observe?: boolean;
history?: boolean;
revision?: string;
} = {},
) {
const connection = await this.access.local(userId);
if (
!connection.membershipAt ||
(options.revision && options.revision !== connection.revision)
)
return { plan: null, handle: null };
if (
intervals.some(
(interval) =>
!connection.accounts.some(
(account) => account.accountId === interval.accountId,
),
)
)
throw new NotFoundException();
const coverage = await this.repository.coverage(
userId,
connection.revision,
intervals.map((interval) => interval.accountId),
);
const now = new Date();
const movingTo = Math.max(
...intervals.map((interval) => interval.to.valueOf()),
);
const targets = intervals.map((interval) =>
options.movingEnd && interval.to.valueOf() === movingTo
? { ...interval, to: now }
: interval,
);
const plan = planOperationRead(targets, coverage, now, options);
const complete = plan.intervals.every(
(interval) => interval.availability === 'complete',
);
const handle =
!options.observe && plan.load.length
? await this.request(
userId,
plan.load,
complete || Boolean(options.force),
'screen',
connection.revision,
)
: null;
if (!options.observe && !options.force && options.history) {
const accountIds = new Set(
intervals.map((interval) => interval.accountId),
);
const history = accountHistory(
connection.accounts.filter((account) =>
accountIds.has(account.accountId),
),
now,
);
const missing = planOperationRead(history, coverage, now, {
movingEnd: true,
}).load;
const remaining = uncoveredOperationIntervals(missing, targets);
if (remaining.length)
await this.request(
userId,
remaining,
false,
'history',
connection.revision,
);
}
return { plan, handle };
}
async request(
userId: string,
intervals: OperationInterval[],
atomic: boolean,
priority: keyof typeof priorities = 'screen',
revision?: string,
): Promise<string | null> {
const connection = await this.access.local(userId);
if (
connection.membershipAt &&
intervals.some(
(interval) =>
!connection.accounts.some(
(account) => account.accountId === interval.accountId,
),
)
)
throw new NotFoundException();
if (revision && revision !== connection.revision) return null;
const cutoff = new Date();
const bounded = operationIntervals(intervals)
.map((interval) => ({
...interval,
to: new Date(Math.min(interval.to.valueOf(), cutoff.valueOf())),
}))
.filter((interval) => interval.from < interval.to);
if (!bounded.length) return null;
const reserve = this.reservations.then(async () => {
const requested = await this.repository.request(
userId,
connection.revision,
operationIntervals(bounded),
this.processRunId,
atomic,
this.runningAttempts,
priorities[priority],
);
if (!requested) return null;
const { id, attemptId, revision } = requested.work;
this.runningAttempts.add(attemptId);
this.ready.set(attemptId, {
attempt: { id, attemptId, userId, revision },
priority: requested.work.priority,
});
if (!this.draining) {
this.draining = true;
// Startup and observation never enter this path.
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserve.catch(() => undefined);
return reserve;
}
async observe(userId: string, id: string) {
const connection = await this.access.local(userId);
const work = await this.repository.observe(userId, connection.revision, id);
if (!work) throw new NotFoundException();
return work;
}
private async drain(): Promise<void> {
try {
while (this.ready.size) {
const selected = [...this.ready.values()].sort(
(a, b) => a.priority - b.priority,
)[0];
const { attempt } = selected;
this.ready.delete(attempt.attemptId);
let pending = false;
try {
pending = await this.step(attempt);
} catch {
this.logger.event(
'error',
'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
);
}
const next = pending ? await this.repository.pending(attempt) : null;
if (next) {
const joined = this.ready.get(attempt.attemptId);
this.ready.set(attempt.attemptId, {
attempt,
priority: Math.min(
next.priority,
joined?.priority ?? next.priority,
),
});
} else {
this.ready.delete(attempt.attemptId);
this.runningAttempts.delete(attempt.attemptId);
}
}
} finally {
this.draining = false;
}
}
private async step(attempt: OperationAttempt): Promise<boolean> {
try {
const plan = await this.repository.pending(attempt);
if (!plan) return false;
if (!plan.next) {
const publish = this.reservations.then(async () => {
const current = await this.repository.pending(attempt);
if (!current) return false;
if (current.next) return true;
const published = await this.repository.publish(
attempt,
current.intervals,
new Date(),
true,
);
return published && Boolean(await this.repository.pending(attempt));
});
this.reservations = publish.catch(() => undefined);
return await publish;
}
const { interval, cursor } = plan.next;
const token = await this.access.tokenForAttempt(attempt);
if (!token) return false;
const response = await this.tbank.getOperationsByCursor(token, {
accountId: interval.accountId,
// Upstream inclusivity is not assumed; storage selects exact [from,to).
from: {
seconds: String(
Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1,
),
},
to: {
seconds: String(
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1,
),
},
...(interval.instrumentUid
? { instrumentId: interval.instrumentUid }
: {}),
cursor,
limit: operationsPageSize,
state: 'OPERATION_STATE_EXECUTED',
});
if (
!(await this.repository.stagePage(attempt, interval, cursor, response))
)
return false;
if (!plan.atomic && !response.hasNext) {
if (
!(await this.repository.publish(
attempt,
[interval],
new Date(),
true,
))
)
return false;
}
// One page per turn: newly requested screen work can overtake backfill.
// TbankClient alone owns the bounded RPC queue and retries.
return true;
} catch (error) {
await this.repository.fail(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
);
return false;
}
}
}