Files
backend/src/modules/investments/operations/domain/operations-sync-plan.ts
T
ksv741 9cc9fbdcff
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
refactor: replace ignored numeric literals with constants
2026-09-28 11:24:59 +03:00

197 lines
6.3 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 type OperationCoverage = OperationInterval & {
asOf: Date;
fetchedAt: Date;
};
const operationsFreshForMs = 300_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;
if (options.force) load.push(target);
else if (stale) load.push({ ...target, from: monthStart, to: now });
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) };
}