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
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:
@@ -0,0 +1,320 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type {
|
||||
Prisma,
|
||||
TbankInstrumentLookup as MetadataLookupRow,
|
||||
TbankInstrument as MetadataRow,
|
||||
} from '../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
|
||||
export type MetadataIdentity = {
|
||||
instrumentUid?: string | null;
|
||||
figi?: string | null;
|
||||
};
|
||||
export type MetadataLookup = {
|
||||
type: 'uid' | 'figi';
|
||||
id: string;
|
||||
data: TbankInstrument | null;
|
||||
};
|
||||
const provider = 'tbank';
|
||||
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||
const metadataReadBatchSize = 400;
|
||||
export function metadataKey(identity: MetadataIdentity) {
|
||||
return JSON.stringify([
|
||||
identity.instrumentUid?.trim() || null,
|
||||
identity.figi?.trim() || null,
|
||||
]);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InstrumentMetadataRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async readMany(identities: MetadataIdentity[]) {
|
||||
const identifiers = identities.flatMap((identity) => [
|
||||
...(identity.instrumentUid?.trim()
|
||||
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||
: []),
|
||||
...(identity.figi?.trim()
|
||||
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||
: []),
|
||||
]);
|
||||
if (!identifiers.length)
|
||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const lookups: MetadataLookupRow[] = [];
|
||||
for (const identifierType of ['uid', 'figi']) {
|
||||
const values = [
|
||||
...new Set(
|
||||
identifiers
|
||||
.filter((entry) => entry.identifierType === identifierType)
|
||||
.map((entry) => entry.identifier),
|
||||
),
|
||||
];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < values.length;
|
||||
offset += metadataReadBatchSize
|
||||
)
|
||||
lookups.push(
|
||||
...(await tx.tbankInstrumentLookup.findMany({
|
||||
where: {
|
||||
provider,
|
||||
identifierType,
|
||||
identifier: {
|
||||
in: values.slice(offset, offset + metadataReadBatchSize),
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
const uids = [
|
||||
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||
];
|
||||
const instruments: MetadataRow[] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < uids.length;
|
||||
offset += metadataReadBatchSize
|
||||
)
|
||||
instruments.push(
|
||||
...(await tx.tbankInstrument.findMany({
|
||||
where: {
|
||||
provider,
|
||||
uid: { in: uids.slice(offset, offset + metadataReadBatchSize) },
|
||||
},
|
||||
})),
|
||||
);
|
||||
const byIdentifier = new Map(
|
||||
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||
);
|
||||
const byUid = new Map(instruments.map((row) => [row.uid, row]));
|
||||
return new Map(
|
||||
identities.map((identity) => [
|
||||
metadataKey(identity),
|
||||
selectMetadata(identity, byIdentifier, byUid),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async reserve(
|
||||
attemptOwner: { userId: string; revision: string },
|
||||
identity: MetadataIdentity,
|
||||
runId: string,
|
||||
active: ReadonlySet<string>,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankConnection.updateMany({
|
||||
where: { ...attemptOwner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!valid.count) return null;
|
||||
const scope = {
|
||||
...attemptOwner,
|
||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||
};
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId: runId,
|
||||
state: 'running',
|
||||
priority: 1,
|
||||
intent: { source: 'metadata', ...identity },
|
||||
progress: {},
|
||||
errorCode: null,
|
||||
};
|
||||
const work = await tx.tbankSyncWork.upsert({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
create: { ...scope, ...data, id: randomUUID() },
|
||||
update: data,
|
||||
});
|
||||
return { work, launch: true };
|
||||
});
|
||||
}
|
||||
|
||||
work(userId: string, revision: string, identity: MetadataIdentity) {
|
||||
return this.prisma.tbankSyncWork.findUnique({
|
||||
where: {
|
||||
userId_revision_scopeKey: {
|
||||
userId,
|
||||
revision,
|
||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||
},
|
||||
},
|
||||
select: { id: true, state: true, errorCode: true },
|
||||
});
|
||||
}
|
||||
|
||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const aliases = new Map(
|
||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (!result.data) continue;
|
||||
const uid =
|
||||
result.data.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||
const figi =
|
||||
result.data.figi?.trim() ||
|
||||
(result.type === 'figi' ? result.id : null);
|
||||
for (const [type, id] of [
|
||||
['uid', uid],
|
||||
['figi', figi],
|
||||
] as const) {
|
||||
if (id && uid && !aliases.has(`${type}:${id}`))
|
||||
aliases.set(`${type}:${id}`, {
|
||||
type,
|
||||
id,
|
||||
data: { ...result.data, uid },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const result of aliases.values()) {
|
||||
const instrument = result.data;
|
||||
const uid =
|
||||
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||
// FIGI is an alias, never a fabricated provider UID.
|
||||
if (instrument && !uid)
|
||||
throw new Error('Metadata response has no instrument UID');
|
||||
if (instrument && uid) {
|
||||
const key = { provider, uid };
|
||||
const previous = await tx.tbankInstrument.findUnique({
|
||||
where: { provider_uid: key },
|
||||
});
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(instrument),
|
||||
) as Prisma.InputJsonObject;
|
||||
const data = {
|
||||
metadata: payload,
|
||||
figi:
|
||||
instrument.figi ?? (result.type === 'figi' ? result.id : null),
|
||||
ticker: instrument.ticker ?? null,
|
||||
instrumentType: instrument.instrumentType ?? '',
|
||||
fetchedAt: now,
|
||||
};
|
||||
await tx.tbankInstrument.upsert({
|
||||
where: { provider_uid: key },
|
||||
create: { ...key, ...data },
|
||||
update: {
|
||||
...data,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||
? 0
|
||||
: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const key = {
|
||||
provider,
|
||||
identifierType: result.type,
|
||||
identifier: result.id,
|
||||
};
|
||||
const matchedUids = instrument && uid ? [uid] : [];
|
||||
const previous = await tx.tbankInstrumentLookup.findUnique({
|
||||
where: { provider_identifierType_identifier: key },
|
||||
});
|
||||
await tx.tbankInstrumentLookup.upsert({
|
||||
where: { provider_identifierType_identifier: key },
|
||||
create: { ...key, matchedUids, fetchedAt: now },
|
||||
update: {
|
||||
matchedUids,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||
? 0
|
||||
: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
...(success ? { connection: { invalidToken: false } } : {}),
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
});
|
||||
if (success)
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: true },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectMetadata(
|
||||
identity: MetadataIdentity,
|
||||
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||
instruments: ReadonlyMap<string, MetadataRow>,
|
||||
) {
|
||||
const resolve = (type: string, id: string | null | undefined) => {
|
||||
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||
const instrument = uid ? instruments.get(uid) : undefined;
|
||||
const data = instrument ? (instrument.metadata as TbankInstrument) : null;
|
||||
return { lookup, instrument, data };
|
||||
};
|
||||
const instrumentUid = identity.instrumentUid?.trim();
|
||||
const instrumentFigi = identity.figi?.trim();
|
||||
const uid = resolve('uid', instrumentUid);
|
||||
const figi = resolve('figi', instrumentFigi);
|
||||
const usefulUid = Boolean(uid.data?.name?.trim() || uid.data?.ticker?.trim());
|
||||
const used =
|
||||
usefulUid || !instrumentFigi ? [uid] : instrumentUid ? [uid, figi] : [figi];
|
||||
const complete = used.every((row) => Boolean(row.lookup));
|
||||
const fetchedAt =
|
||||
used
|
||||
.flatMap((row) => (row.lookup ? [row.lookup.fetchedAt] : []))
|
||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
|
||||
return {
|
||||
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||
complete,
|
||||
fresh:
|
||||
complete &&
|
||||
fetchedAt !== null &&
|
||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||
fetchedAt,
|
||||
dataRevision: used
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
||||
)
|
||||
.join('/'),
|
||||
};
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { LRUCache } from 'lru-cache';
|
||||
import assert from 'node:assert/strict';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
describe('InstrumentMetadataService', () => {
|
||||
const tbank = {
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const logger = { event: jest.fn() };
|
||||
let service: InstrumentMetadataService;
|
||||
|
||||
function rememberExpired(key: string, value: TbankInstrument): void {
|
||||
const cache = (
|
||||
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
|
||||
).metadata;
|
||||
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tbank.getInstrumentByFigi.mockReset();
|
||||
tbank.getInstrumentByUid.mockReset();
|
||||
service = new InstrumentMetadataService(tbank as never, logger as never);
|
||||
});
|
||||
|
||||
it('prefers the instrument UID when metadata is available', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue({
|
||||
name: 'Инструмент',
|
||||
brand: { logoName: 'instrument.png' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({
|
||||
brand: { logoName: 'instrument.png' },
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to FIGI when the UID has no matching instrument', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({
|
||||
ticker: 'SBER',
|
||||
brand: { logoName: 'sber.png' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({
|
||||
ticker: 'SBER',
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
|
||||
});
|
||||
|
||||
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
|
||||
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('caches negative lookups and avoids repeated upstream requests', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({ name: 'Облигация' });
|
||||
});
|
||||
|
||||
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
|
||||
const known = {
|
||||
name: 'Облигация',
|
||||
ticker: 'BOND',
|
||||
brand: { logoName: 'bond.png' },
|
||||
};
|
||||
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
|
||||
await service.resolve('token', 'uid-1', null);
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
tbank.getInstrumentByUid.mockResolvedValue({
|
||||
...known,
|
||||
name: 'Новое название',
|
||||
});
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', null),
|
||||
).resolves.toMatchObject({ name: 'Новое название' });
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
|
||||
const error = new Error('INVALID_TBANK_TOKEN');
|
||||
tbank.getInstrumentByUid.mockRejectedValue(error);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
|
||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid
|
||||
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
|
||||
.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
|
||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
||||
rememberExpired('figi:figi-1', known);
|
||||
tbank.getInstrumentByUid
|
||||
.mockResolvedValueOnce({ isin: 'RU0000000001' })
|
||||
.mockResolvedValueOnce({ name: 'Новое название' });
|
||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
|
||||
name: 'Новое название',
|
||||
});
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cold partial fields without caching them', async () => {
|
||||
const partial = { isin: 'RU0000000001' };
|
||||
tbank.getInstrumentByUid.mockResolvedValue(partial);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
partial,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
partial,
|
||||
);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('replaces an expired name with authoritative confirmed absence', async () => {
|
||||
rememberExpired('uid:uid-1', { name: 'Старое название' });
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
||||
const error = new Error('INVALID_TBANK_TOKEN');
|
||||
const known = { name: 'Облигация' };
|
||||
let rejectInvalid: ((reason: Error) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
||||
token === 'invalid-token'
|
||||
? new Promise((_resolve, reject) => {
|
||||
rejectInvalid = reject;
|
||||
})
|
||||
: Promise.resolve(known),
|
||||
);
|
||||
|
||||
const invalid = expect(
|
||||
service.resolve('invalid-token', 'uid-1', null),
|
||||
).rejects.toBe(error);
|
||||
const valid = service.resolve('valid-token', 'uid-1', null);
|
||||
assert.ok(rejectInvalid);
|
||||
rejectInvalid(error);
|
||||
|
||||
await invalid;
|
||||
await expect(valid).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('another-token', 'uid-1', null),
|
||||
).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns cached or nullable metadata without starting requests after abort', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
rememberExpired('figi:figi-1', known);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
|
||||
).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
|
||||
).resolves.toBeNull();
|
||||
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expired metadata promptly when the waiting caller aborts', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const result = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(result).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
let finish: ((value: TbankInstrument) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const aborted = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(aborted).resolves.toBeNull();
|
||||
|
||||
const waiting = service.resolve('token', 'uid-1', null);
|
||||
assert.ok(finish);
|
||||
finish(known);
|
||||
|
||||
await expect(waiting).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('another-token', 'uid-1', null),
|
||||
).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
||||
let fail: ((reason: Error) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const result = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBeNull();
|
||||
|
||||
assert.ok(fail);
|
||||
fail(new Error('INVALID_TBANK_TOKEN'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,118 +1,208 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
import {
|
||||
InstrumentMetadataRepository,
|
||||
metadataKey,
|
||||
type MetadataIdentity,
|
||||
type MetadataLookup,
|
||||
} from './instrument-metadata.repository';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
|
||||
const metadataForMs = 86_400_000;
|
||||
const maxCachedInstruments = 500;
|
||||
const missingInstrument = Symbol('missingInstrument');
|
||||
|
||||
type Producer = {
|
||||
identity: MetadataIdentity;
|
||||
attempt: TbankSyncAttempt;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@Injectable()
|
||||
export class InstrumentMetadataService {
|
||||
private readonly metadata = new LRUCache<
|
||||
string,
|
||||
TbankInstrument | typeof missingInstrument
|
||||
>({
|
||||
max: maxCachedInstruments,
|
||||
ttl: metadataForMs,
|
||||
noDeleteOnStaleGet: true,
|
||||
});
|
||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly repository: InstrumentMetadataRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
token: string,
|
||||
instrumentUid: string | null | undefined,
|
||||
figi: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const uid = instrumentUid?.trim();
|
||||
let byUid: TbankInstrument | null = null;
|
||||
if (uid) {
|
||||
byUid = await this.lookup(
|
||||
token,
|
||||
`uid:${uid}`,
|
||||
() => this.tbank.getInstrumentByUid(token, uid),
|
||||
signal,
|
||||
savedMany(identities: MetadataIdentity[]) {
|
||||
return this.repository.readMany(identities);
|
||||
}
|
||||
|
||||
async read(
|
||||
userId: string,
|
||||
identity: MetadataIdentity,
|
||||
observe = false,
|
||||
revision?: string,
|
||||
) {
|
||||
const connection = await this.access.local(userId);
|
||||
if (revision && connection.revision !== revision) return null;
|
||||
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||
if (!observe && !data?.fresh)
|
||||
await this.request(userId, connection.revision, identity);
|
||||
return {
|
||||
...data,
|
||||
work: await this.repository.work(userId, connection.revision, identity),
|
||||
};
|
||||
}
|
||||
|
||||
async request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
identity: MetadataIdentity,
|
||||
force = false,
|
||||
) {
|
||||
identity = {
|
||||
instrumentUid: identity.instrumentUid?.trim() || null,
|
||||
figi: identity.figi?.trim() || null,
|
||||
};
|
||||
if (!identity.instrumentUid && !identity.figi) return null;
|
||||
const normalized = identity;
|
||||
const reserved = this.reservations.then(async () => {
|
||||
const snapshot = (await this.savedMany([normalized])).get(
|
||||
metadataKey(normalized),
|
||||
);
|
||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
||||
}
|
||||
const instrumentFigi = figi?.trim();
|
||||
const byFigi = instrumentFigi
|
||||
? await this.lookup(
|
||||
token,
|
||||
`figi:${instrumentFigi}`,
|
||||
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
||||
signal,
|
||||
)
|
||||
: null;
|
||||
return byFigi ?? byUid;
|
||||
}
|
||||
|
||||
private async lookup(
|
||||
token: string,
|
||||
key: string,
|
||||
fetch: () => Promise<TbankInstrument | null>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const cached = this.metadata.get(key);
|
||||
if (cached !== undefined)
|
||||
return cached === missingInstrument ? null : cached;
|
||||
if (signal?.aborted) return this.lastKnown(key);
|
||||
const owner = createHash('sha256').update(token).digest('hex');
|
||||
const requestKey = `${owner}:${key}`;
|
||||
let pending = this.pending.get(requestKey);
|
||||
if (!pending) {
|
||||
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
|
||||
this.pending.delete(requestKey);
|
||||
});
|
||||
this.pending.set(requestKey, pending);
|
||||
}
|
||||
if (!signal) return pending;
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => resolve(this.lastKnown(key));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
void pending.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
if (!force && snapshot?.fresh) return null;
|
||||
const result = await this.repository.reserve(
|
||||
{ userId, revision },
|
||||
normalized,
|
||||
this.runId,
|
||||
this.active,
|
||||
);
|
||||
if (!result) return null;
|
||||
const { id, attemptId } = result.work;
|
||||
if (!result.launch) return id;
|
||||
const attempt = { userId, revision, id, attemptId };
|
||||
this.active.add(attemptId);
|
||||
const key = metadataKey(normalized);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) producer.observers.push(attempt);
|
||||
else {
|
||||
const queued = { identity: normalized, attempt, observers: [] };
|
||||
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;
|
||||
}
|
||||
|
||||
private lastKnown(key: string): TbankInstrument | null {
|
||||
const value = this.metadata.peek(key, { allowStale: true });
|
||||
return value === missingInstrument ? null : (value ?? null);
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
key: string,
|
||||
request: () => Promise<TbankInstrument | null>,
|
||||
previous: TbankInstrument | null,
|
||||
): Promise<TbankInstrument | null> {
|
||||
private async drain() {
|
||||
try {
|
||||
const value = await request();
|
||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
||||
if (value === null || found)
|
||||
this.metadata.set(key, value ?? missingInstrument);
|
||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
||||
found,
|
||||
while (this.queue.length) {
|
||||
const producer = this.queue.shift();
|
||||
if (!producer) break;
|
||||
const { attempt, identity } = producer;
|
||||
let success = false;
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (token) {
|
||||
const results: MetadataLookup[] = [];
|
||||
let useful = false;
|
||||
let uidFailed = false;
|
||||
if (identity.instrumentUid) {
|
||||
let data: TbankInstrument | null | undefined;
|
||||
try {
|
||||
data = await this.tbank.getInstrumentByUid(
|
||||
token,
|
||||
identity.instrumentUid,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
)
|
||||
throw error;
|
||||
uidFailed = true;
|
||||
}
|
||||
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||
throw new Error('Metadata UID mismatch');
|
||||
if (data !== undefined)
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||
if (!useful && data && !identity.figi)
|
||||
throw new Error('Incomplete metadata');
|
||||
}
|
||||
if (!useful && identity.figi) {
|
||||
const data = await this.tbank.getInstrumentByFigi(
|
||||
token,
|
||||
identity.figi,
|
||||
);
|
||||
if (data?.figi && data.figi !== identity.figi)
|
||||
throw new Error('Metadata FIGI mismatch');
|
||||
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||
throw new Error('Incomplete metadata');
|
||||
if (data === undefined || (uidFailed && !data))
|
||||
throw new Error('Metadata source unavailable');
|
||||
results.push({ type: 'figi', id: identity.figi, data });
|
||||
// A successful FIGI response identifies its canonical UID too;
|
||||
// a failed UID RPC is never persisted as a negative lookup.
|
||||
if (
|
||||
uidFailed &&
|
||||
data?.uid === identity.instrumentUid &&
|
||||
identity.instrumentUid
|
||||
)
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
}
|
||||
if (
|
||||
!results.length ||
|
||||
(results.some((result) => result.data) &&
|
||||
!results.some(
|
||||
(result) =>
|
||||
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||
))
|
||||
)
|
||||
throw new Error('Incomplete metadata');
|
||||
success = await this.repository.publish(
|
||||
attempt,
|
||||
results,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
// Remove the producer before awaiting finalization: a new request cannot
|
||||
// attach to a producer whose observer list has already been drained.
|
||||
this.producers.delete(metadataKey(identity));
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
await this.repository.finishObserver(observer, success);
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
return found || value === null ? value : (previous ?? value);
|
||||
} catch (error) {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code:
|
||||
error instanceof Error && 'response' in error
|
||||
? ((error as { response?: { code?: string } }).response?.code ??
|
||||
'UNKNOWN')
|
||||
: 'UNKNOWN',
|
||||
});
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw error;
|
||||
return error instanceof ServiceUnavailableException ? previous : null;
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
SupportedTbankAccount,
|
||||
@@ -22,6 +24,13 @@ export type TbankAccountContext = TbankConnectionContext & {
|
||||
account: TbankAccount;
|
||||
};
|
||||
|
||||
export type TbankSyncAttempt = {
|
||||
userId: string;
|
||||
revision: string;
|
||||
id: string;
|
||||
attemptId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TbankAccessService {
|
||||
constructor(
|
||||
@@ -30,6 +39,219 @@ export class TbankAccessService {
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
|
||||
async reserveMembership(
|
||||
userId: string,
|
||||
revision: string,
|
||||
processRunId: string,
|
||||
active: ReadonlySet<string>,
|
||||
force: boolean,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { userId, revision, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
if (
|
||||
!force &&
|
||||
!tbankSourceNeedsRefresh(
|
||||
'membership',
|
||||
connection.membershipAt,
|
||||
new Date(),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const scope = { userId, revision, scopeKey: 'membership' };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId,
|
||||
state: 'running',
|
||||
intent: { source: 'membership' },
|
||||
progress: {},
|
||||
priority: 0,
|
||||
errorCode: null,
|
||||
};
|
||||
const work = await tx.tbankSyncWork.upsert({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
create: { ...scope, ...data, id: randomUUID() },
|
||||
update: data,
|
||||
});
|
||||
return { work, launch: true };
|
||||
});
|
||||
}
|
||||
|
||||
membershipWork(userId: string, revision: string) {
|
||||
return this.prisma.tbankSyncWork.findUnique({
|
||||
where: {
|
||||
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
|
||||
},
|
||||
select: { id: true, state: true, errorCode: true, updatedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
});
|
||||
return result.count === 1;
|
||||
}
|
||||
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
select: { connection: { select: { encryptedToken: true } } },
|
||||
});
|
||||
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||
}
|
||||
|
||||
async publishMembership(
|
||||
attempt: TbankSyncAttempt,
|
||||
accounts: TbankAccount[],
|
||||
fetchedAt: Date,
|
||||
completeWork = false,
|
||||
): Promise<boolean> {
|
||||
const supported = supportedTbankAccounts(accounts);
|
||||
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
||||
throw new Error('Duplicate T-Bank account identity');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Acquire the write fence before reading membership. Replacement and
|
||||
// attempt supersession cannot interleave with the publication below.
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const { userId, revision } = attempt;
|
||||
const previous = await tx.tbankAccount.findMany({
|
||||
where: { userId, revision },
|
||||
orderBy: { upstreamOrder: 'asc' },
|
||||
select: { accountId: true, type: true, name: true, openedOn: true },
|
||||
});
|
||||
const next = supported.map(({ id, ...account }) => ({
|
||||
accountId: id,
|
||||
...account,
|
||||
}));
|
||||
const changed =
|
||||
previous.length !== next.length ||
|
||||
previous.some((account, index) => {
|
||||
const incoming = next[index];
|
||||
return (
|
||||
account.accountId !== incoming.accountId ||
|
||||
account.type !== incoming.type ||
|
||||
account.name !== incoming.name ||
|
||||
account.openedOn !== incoming.openedOn
|
||||
);
|
||||
});
|
||||
await tx.tbankAccount.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
accountId: { notIn: supported.map(({ id }) => id) },
|
||||
},
|
||||
});
|
||||
for (const [upstreamOrder, account] of next.entries()) {
|
||||
await tx.tbankAccount.upsert({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId,
|
||||
revision,
|
||||
accountId: account.accountId,
|
||||
},
|
||||
},
|
||||
create: { userId, revision, ...account, upstreamOrder },
|
||||
update: { ...account, upstreamOrder },
|
||||
});
|
||||
}
|
||||
await tx.tbankConnection.update({
|
||||
where: { userId_revision: { userId, revision } },
|
||||
data: {
|
||||
membershipAt: fetchedAt,
|
||||
membershipRevision: { increment: changed ? 1 : 0 },
|
||||
},
|
||||
});
|
||||
if (completeWork)
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
await tx.tbankConnection.update({
|
||||
where: {
|
||||
userId_revision: {
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
},
|
||||
},
|
||||
data: { invalidToken: true },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// This boundary neither decrypts credentials nor starts discovery/sync.
|
||||
// Missing membership is distinct from a successfully observed empty list.
|
||||
async local(userId: string, accountId?: string) {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
userId: true,
|
||||
revision: true,
|
||||
invalidToken: true,
|
||||
membershipAt: true,
|
||||
membershipRevision: true,
|
||||
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!connection)
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
if (connection.invalidToken)
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
accountId &&
|
||||
accountId !== 'all' &&
|
||||
!connection.accounts.some((account) => account.accountId === accountId)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
return connection;
|
||||
}
|
||||
|
||||
async listSupportedAccounts(
|
||||
userId: string,
|
||||
): Promise<SupportedTbankAccount[]> {
|
||||
@@ -97,6 +319,8 @@ export class TbankAccessService {
|
||||
});
|
||||
if (!connection)
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
if (connection.invalidToken)
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
return {
|
||||
token: this.tokens.decrypt(connection.encryptedToken),
|
||||
generation: createHash('sha256')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Injectable()
|
||||
export class TbankMembershipService {
|
||||
private readonly processRunId = randomUUID();
|
||||
private readonly active = new Map<string, Promise<void>>();
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
request(userId: string, revision: string, force = false) {
|
||||
const reservation = this.reservations.then(async () => {
|
||||
const requested = await this.access.reserveMembership(
|
||||
userId,
|
||||
revision,
|
||||
this.processRunId,
|
||||
new Set(this.active.keys()),
|
||||
force,
|
||||
);
|
||||
if (!requested) return null;
|
||||
const { id, attemptId } = requested.work;
|
||||
if (requested.launch) {
|
||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||
.then(() => this.execute({ userId, revision, id, attemptId }))
|
||||
.catch(() =>
|
||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||
userId,
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
}),
|
||||
)
|
||||
.finally(() => this.active.delete(attemptId));
|
||||
this.active.set(attemptId, done);
|
||||
}
|
||||
return { id, done: this.active.get(attemptId) ?? Promise.resolve() };
|
||||
});
|
||||
this.reservations = reservation.catch(() => undefined);
|
||||
return reservation;
|
||||
}
|
||||
|
||||
private async execute(attempt: TbankSyncAttempt) {
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (!token) return;
|
||||
await this.access.publishMembership(
|
||||
attempt,
|
||||
await this.tbank.getOpenAccounts(token),
|
||||
new Date(),
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
await this.access.failMembership(
|
||||
attempt,
|
||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
describe('request-driven source freshness', () => {
|
||||
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||
'%s expires at its boundary without changing source time',
|
||||
(source) => {
|
||||
const fetchedAt = new Date('2026-09-01Z');
|
||||
const ttl = tbankSourceTtlMs[source];
|
||||
expect(
|
||||
tbankSourceNeedsRefresh(
|
||||
source,
|
||||
fetchedAt,
|
||||
new Date(fetchedAt.valueOf() + ttl - 1000),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
tbankSourceNeedsRefresh(
|
||||
source,
|
||||
fetchedAt,
|
||||
new Date(fetchedAt.valueOf() + ttl),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(tbankSourceNeedsRefresh(source, fetchedAt, fetchedAt, true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(tbankSourceNeedsRefresh(source, null, fetchedAt)).toBe(true);
|
||||
expect(fetchedAt.toISOString()).toBe('2026-09-01T00:00:00.000Z');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||
export const tbankSourceTtlMs = {
|
||||
membership: 300_000,
|
||||
portfolio: 60_000,
|
||||
quote: 60_000,
|
||||
candleTail: 60_000,
|
||||
operationsTail: 300_000,
|
||||
metadata: 86_400_000,
|
||||
catalog: 86_400_000,
|
||||
company: 86_400_000,
|
||||
calendar: 86_400_000,
|
||||
} as const;
|
||||
|
||||
export function tbankSourceNeedsRefresh(
|
||||
source: keyof typeof tbankSourceTtlMs,
|
||||
fetchedAt: Date | null,
|
||||
now: Date,
|
||||
force = false,
|
||||
): boolean {
|
||||
return (
|
||||
force ||
|
||||
!fetchedAt ||
|
||||
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,30 @@ export {
|
||||
export {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankCoupon,
|
||||
type TbankFundamentals,
|
||||
type TbankShare,
|
||||
} from './tbank-market.types';
|
||||
export { TbankTokenService } from './tbank-token.service';
|
||||
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||
export {
|
||||
TbankClient,
|
||||
type TbankDividend,
|
||||
type TbankInstrument,
|
||||
type TbankMoney,
|
||||
type TbankOperationItem,
|
||||
type TbankOperationsByCursorResponse,
|
||||
type TbankPortfolio,
|
||||
type TbankPositions,
|
||||
type TbankTimestamp,
|
||||
} from './tbank.client';
|
||||
|
||||
export { TbankMembershipService } from './tbank-membership.service';
|
||||
|
||||
export {
|
||||
tbankSourceTtlMs,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
export { metadataKey } from './instrument-metadata.service';
|
||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||
|
||||
@@ -66,6 +66,9 @@ export type TbankPortfolio = {
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = {
|
||||
uid?: string;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
name?: string;
|
||||
isin?: string;
|
||||
@@ -80,6 +83,7 @@ export type TbankDividend = {
|
||||
};
|
||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||
export type TbankOperationItem = {
|
||||
state?: string;
|
||||
cursor?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -233,8 +237,8 @@ export class TbankClient {
|
||||
this.logger.info('tbank.client.configured');
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<void> {
|
||||
await this.getOpenAccounts(token);
|
||||
validateToken(token: string): Promise<TbankAccount[]> {
|
||||
return this.getOpenAccounts(token);
|
||||
}
|
||||
|
||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TbankMembershipService } from './tbank-membership.service';
|
||||
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
@@ -9,12 +11,15 @@ import { TbankClient } from './tbank.client';
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
InstrumentMetadataService,
|
||||
InstrumentMetadataRepository,
|
||||
],
|
||||
exports: [
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
InstrumentMetadataService,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user