Files
backend/src/modules/investments/portfolio/infrastructure/forecast.repository.ts
T
ksv741 d698ed9425
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
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.
2026-10-03 11:38:08 +03:00

157 lines
4.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
const FIRST_BATCH_OFFSET = 0;
export type ForecastSourceVersion = {
dataRevision: number;
fetchedAt: Date;
instrumentUid: string;
kind: string;
rangeKey: string;
source: string;
};
const forecastSourceReadBatchSize = 100;
@Injectable()
export class ForecastRepository {
constructor(private readonly prisma: PrismaService) {}
resolve(input: {
candidate?: object;
membershipRevision: number;
portfolioProjection: string;
portfolios: { accountId: string; dataRevision: number }[];
revision: string;
scopeKey: string;
sources: ForecastSourceVersion[];
timestamps: { at: string; resourceKey: string }[];
userId: string;
}) {
return this.prisma.$transaction(async (tx) => {
const owner = { userId: input.userId, revision: input.revision };
if (input.candidate) {
const current = await tx.tbankConnection.updateMany({
where: {
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) 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 },
});
const fallback =
previous?.membershipRevision === input.membershipRevision
? previous
: null;
if (!input.candidate) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({
where: {
...owner,
accountId: { in: input.portfolios.map((row) => row.accountId) },
},
select: { accountId: true, dataRevision: true },
});
if (
input.portfolios.some(
(expected) =>
!portfolios.some(
(row) =>
row.accountId === expected.accountId &&
row.dataRevision === expected.dataRevision,
),
)
) {
return fallback;
}
// Four identity parameters per source: stay below SQLite's parameter limit.
const sources: ForecastSourceVersion[] = [];
for (
let offset = FIRST_BATCH_OFFSET;
offset < input.sources.length;
offset += forecastSourceReadBatchSize
) {
sources.push(
...(await tx.tbankInstrumentSource.findMany({
where: {
provider: 'tbank',
OR: input.sources
.slice(offset, offset + forecastSourceReadBatchSize)
.map(({ instrumentUid, source, kind, rangeKey }) => ({
instrumentUid,
source,
kind,
rangeKey,
})),
},
select: {
instrumentUid: true,
source: true,
kind: true,
rangeKey: true,
dataRevision: true,
fetchedAt: true,
},
})),
);
}
if (
input.sources.some(
(expected) =>
!sources.some(
(row) =>
row.instrumentUid === expected.instrumentUid &&
row.source === expected.source &&
row.kind === expected.kind &&
row.rangeKey === expected.rangeKey &&
row.dataRevision === expected.dataRevision &&
row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(),
),
)
) {
return fallback;
}
const revisions = JSON.parse(
JSON.stringify({
portfolioProjection: input.portfolioProjection,
portfolios: input.portfolios,
sources: input.sources,
}),
) as Prisma.InputJsonObject;
const timestamps = input.timestamps as Prisma.InputJsonArray;
if (
fallback &&
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
) {
return fallback;
}
const data = {
membershipRevision: input.membershipRevision,
payload: JSON.parse(
JSON.stringify(input.candidate),
) as Prisma.InputJsonObject,
dependencyRevisions: revisions,
sourceTimestamps: timestamps,
calculatedAt: new Date(),
};
return tx.tbankForecast.upsert({
where: { userId_revision_scopeKey: key },
create: { ...key, ...data },
update: { ...data, dataRevision: { increment: 1 } },
});
});
}
}