Files
backend/src/modules/investments/operations/domain/operations-sync-plan.ts
T
ksv741 0057925888
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
feat: add hourly portfolio sync history
2026-10-09 09:04:41 +03:00

216 lines
7.0 KiB
TypeScript

export const operationsSyncPriority = { screen: 1, history: 2 } as const;
const LAST_INTERVAL_INDEX = -1;
const NEXT_MONTH_OFFSET = 1;
const FIRST_DAY_OF_MONTH = 1;
const EMPTY_INTERVAL_COUNT = 0;
const EMPTY_SOURCE_COUNT = 0;
export type OperationInterval = {
accountId: string;
from: Date;
instrumentUid?: string;
to: Date;
};
// Normalize duplicate/overlapping targets before creating a durable identity.
// Calendar windows keep each provider walk bounded without changing [from,to).
export function operationIntervals(
requested: OperationInterval[],
): OperationInterval[] {
const groups = new Map<string, OperationInterval[]>();
for (const interval of requested) {
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) {
throw new Error('Invalid operations interval');
}
const key = JSON.stringify([
interval.accountId,
interval.instrumentUid ?? '',
]);
const group = groups.get(key) ?? [];
group.push(interval);
groups.set(key, group);
}
const result: OperationInterval[] = [];
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
for (const key of [...groups.keys()].sort()) {
const merged: OperationInterval[] = [];
for (const interval of (groups.get(key) ?? []).sort(
(left, right) => left.from.valueOf() - right.from.valueOf(),
)) {
const previous = merged.at(LAST_INTERVAL_INDEX);
if (previous && interval.from <= previous.to) {
previous.to = new Date(
Math.max(previous.to.valueOf(), interval.to.valueOf()),
);
} else merged.push({ ...interval });
}
for (const interval of merged) {
let from = interval.from;
while (from < interval.to) {
const nextMonth = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + NEXT_MONTH_OFFSET, FIRST_DAY_OF_MONTH),
);
const to = new Date(
Math.min(nextMonth.valueOf(), interval.to.valueOf()),
);
result.push({ ...interval, from, to });
from = to;
}
}
}
return result;
}
export function operationIntervalsOverlap(
left: OperationInterval,
right: OperationInterval,
): boolean {
return (
left.accountId === right.accountId &&
(!left.instrumentUid ||
!right.instrumentUid ||
left.instrumentUid === right.instrumentUid) &&
left.from < right.to &&
right.from < left.to
);
}
export function uncoveredOperationIntervals(
requested: OperationInterval[],
existing: OperationInterval[],
): OperationInterval[] {
return requested.flatMap((interval) => {
let fragments = [interval];
for (const covered of existing) {
fragments = fragments.flatMap((fragment) => {
if (
!operationIntervalsOverlap(fragment, covered) ||
(covered.instrumentUid &&
covered.instrumentUid !== fragment.instrumentUid)
) {
return [fragment];
}
return [
...(fragment.from < covered.from
? [{ ...fragment, to: covered.from }]
: []),
...(covered.to < fragment.to
? [{ ...fragment, from: covered.to }]
: []),
];
});
}
return fragments;
});
}
export function confirmedOperationRanges(
targets: OperationInterval[],
coverage: OperationInterval[],
): Omit<OperationInterval, 'instrumentUid'>[] {
return targets.flatMap((target) => coverage
.filter((row) =>
row.accountId === target.accountId &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
row.from < target.to && target.from < row.to,
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
}
export type OperationCoverage = OperationInterval & {
asOf: Date;
fetchedAt: Date;
};
const operationsFreshForMs = 3_600_000;
export function planOperationRead(
requested: OperationInterval[],
coverage: OperationCoverage[],
now: Date,
options: { force?: boolean; movingEnd?: boolean } = {},
) {
const monthStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH),
);
const observedEnd = Math.max(
...requested.map((interval) => interval.to.valueOf()),
);
const load: OperationInterval[] = [];
const intervals = operationIntervals(requested).map((target) => {
const sources = coverage.filter(
(row) =>
row.accountId === target.accountId &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
row.from < target.to &&
target.from < row.to,
);
const ranges = sources.map((row) => ({
...row,
instrumentUid: target.instrumentUid,
}));
const current = target.from >= monthStart && target.to <= now;
const fresh = sources.filter(
(row) => now.valueOf() - row.fetchedAt.valueOf() < operationsFreshForMs,
);
let effective = target;
if (options.movingEnd && target.to.valueOf() === observedEnd) {
const latest = sources
.filter(
(row) =>
row.asOf.valueOf() === row.to.valueOf() && row.to > target.from,
)
.sort((left, right) => right.to.valueOf() - left.to.valueOf())[0];
if (latest && latest.to < target.to) {
effective = { ...target, to: latest.to };
}
}
const missing = uncoveredOperationIntervals([effective], ranges);
const complete = missing.length === EMPTY_INTERVAL_COUNT;
const stale =
current &&
sources.length > EMPTY_SOURCE_COUNT &&
uncoveredOperationIntervals(
ranges
.map((row) => ({
...target,
from: new Date(
Math.max(effective.from.valueOf(), row.from.valueOf()),
),
to: new Date(Math.min(effective.to.valueOf(), row.to.valueOf())),
}))
.filter((row) => row.from < row.to),
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
).length > EMPTY_INTERVAL_COUNT;
// The mutable tail is owned by the hourly walk; reads only load what is
// missing, never re-verify complete or fresh intervals.
if (options.force) load.push(target);
else load.push(...missing);
// Completeness is bounded by the observed end, never by the new wall clock.
return {
...effective,
availability: complete
? ('complete' as const)
: sources.length
? ('partial' as const)
: ('missing' as const),
stale,
asOf: sources.length
? new Date(
Math.min(
effective.to.valueOf(),
Math.max(...sources.map((row) => row.asOf.valueOf())),
),
)
: null,
};
});
return { intervals, load: operationIntervals(load) };
}