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
197 lines
6.3 KiB
TypeScript
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) };
|
|
}
|