feat(observatory): add bounded M5.1 session review

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 17:29:39 +03:00
parent 81fdf6904a
commit e6a9846167
14 changed files with 1099 additions and 2 deletions
@@ -0,0 +1,145 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
export interface ObservatoryEvidence {
readonly sessionId: string;
readonly label: string;
readonly status: ObservationSessionSummary["status"];
readonly publishedAtUtc: string;
readonly lab: ObservationLabInstance;
}
export interface ObservatorySession {
readonly source: ObservationSessionSummary;
readonly evidence: readonly ObservatoryEvidence[];
}
export interface ObservatoryCatalog {
readonly items: readonly ObservatorySession[];
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
readonly window: {
readonly limit: number;
readonly sourceCount: number;
readonly laboratoryCount: number;
readonly sourceLimitReached: boolean;
readonly laboratoryLimitReached: boolean;
};
}
export class ObservatoryCatalogContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryCatalogContractError";
}
}
function newestFirst(left: string, right: string): number {
return Date.parse(right) - Date.parse(left);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
}
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
};
}
function sortEvidence(
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] {
return [...evidence].sort((left, right) =>
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|| left.sessionId.localeCompare(right.sessionId));
}
export function buildObservatoryCatalog(
sourceSessions: readonly ObservationSessionSummary[],
laboratorySessions: readonly ObservationSessionSummary[],
limit = 100,
): ObservatoryCatalog {
const safeLimit = boundedLimit(limit);
const sources = new Map<string, ObservationSessionSummary>();
for (const source of sourceSessions) {
if (source.lab !== null) {
throw new ObservatoryCatalogContractError(
`Каталог источников вернул LAB-сессию ${source.id}.`,
);
}
sources.set(source.id, source);
}
const linked = new Map<string, ObservatoryEvidence[]>();
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
unresolvedEvidence.push(evidence);
continue;
}
const current = linked.get(sourceId) ?? [];
current.push(evidence);
linked.set(sourceId, current);
}
const items = [...sources.values()]
.sort((left, right) =>
newestFirst(left.startedAtUtc, right.startedAtUtc)
|| left.id.localeCompare(right.id))
.map((source) => ({
source,
evidence: sortEvidence(linked.get(source.id) ?? []),
}));
return {
items,
unresolvedEvidence: sortEvidence(unresolvedEvidence),
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
};
}
export async function fetchObservatoryCatalog({
signal,
limit = 100,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
}
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryCatalog,
type ObservatoryCatalog,
} from "./catalog";
export type ObservatoryCatalogState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "error";
export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
readonly error: string | null;
readonly refresh: () => void;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог Обсерватории недоступен.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function useObservatoryCatalog(): ObservatoryCatalogController {
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
const [state, setState] = useState<ObservatoryCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const catalogRef = useRef<ObservatoryCatalog | null>(null);
useEffect(() => {
const controller = new AbortController();
setState(catalogRef.current ? "refreshing" : "loading");
setError(null);
void fetchObservatoryCatalog({ signal: controller.signal })
.then((next) => {
if (controller.signal.aborted) return;
catalogRef.current = next;
setCatalog(next);
setState("ready");
})
.catch((loadError: unknown) => {
if (controller.signal.aborted || isAbortError(loadError)) return;
setError(errorMessage(loadError));
setState("error");
});
return () => controller.abort();
}, [generation]);
const refresh = useCallback(() => {
setGeneration((current) => current + 1);
}, []);
return { catalog, state, error, refresh };
}