Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps. Co-Authored-By: Claude Code <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { fetchChartView } from "@/lib/chart-view-client";
|
|
import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject";
|
|
import type { ChartViewResponse } from "@/lib/chart-view-contract";
|
|
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
|
|
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
|
|
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
|
|
|
export const REPORTS_WAITING_COPY = "报告列表还没拿到。";
|
|
export const EPHEMERIS_WAITING_COPY = "这一天的星历还没拿到。";
|
|
|
|
export type ChartPageSnapshot =
|
|
| { kind: "view"; view: ChartViewResponse }
|
|
| { kind: "unauthenticated" };
|
|
|
|
export type EphemerisPageSnapshot =
|
|
| { kind: "ready"; payload: EphemerisOkResponse }
|
|
| { kind: "unauthorized" };
|
|
|
|
export type ReportListItem = Readonly<{
|
|
id: string;
|
|
reportType: string;
|
|
presentationMode: string;
|
|
depth: string;
|
|
themes: readonly string[];
|
|
status: "generating" | "ready" | "failed";
|
|
failureCode: string | null;
|
|
failureSummary?: string | null;
|
|
createdAt: string;
|
|
completedAt: string | null;
|
|
cardSummary?: string | null;
|
|
}>;
|
|
|
|
export type ReportsPageSnapshot =
|
|
| { kind: "ready"; reports: readonly ReportListItem[] }
|
|
| { kind: "unauthorized" }
|
|
| { kind: "error"; reports: readonly ReportListItem[] };
|
|
|
|
type MemoryCache<T> = {
|
|
peek: () => T | null;
|
|
write: (value: T) => T;
|
|
refresh: () => Promise<T>;
|
|
prefetch: () => void;
|
|
reset: () => void;
|
|
};
|
|
|
|
function createMemoryCache<T>(load: () => Promise<T>): MemoryCache<T> {
|
|
let value: T | null = null;
|
|
let inflight: Promise<T> | null = null;
|
|
let epoch = 0;
|
|
let scope = "";
|
|
const currentScope = () => JSON.stringify([readCurrentSubjectAccountId(), readCurrentSubjectId()]);
|
|
const syncScope = () => {
|
|
const next = currentScope();
|
|
if (scope === next) return;
|
|
scope = next;
|
|
epoch += 1;
|
|
value = null;
|
|
inflight = null;
|
|
};
|
|
|
|
const refresh = (): Promise<T> => {
|
|
syncScope();
|
|
if (inflight) return inflight;
|
|
const requestEpoch = epoch;
|
|
const requestScope = scope;
|
|
const request = load()
|
|
.then((next) => {
|
|
syncScope();
|
|
if (scope !== requestScope) throw new Error("secondary_page_scope_changed");
|
|
if (epoch === requestEpoch) value = next;
|
|
return next;
|
|
})
|
|
.finally(() => {
|
|
if (inflight === request) inflight = null;
|
|
});
|
|
inflight = request;
|
|
return request;
|
|
};
|
|
|
|
return {
|
|
peek: () => { syncScope(); return value; },
|
|
write: (next) => {
|
|
syncScope();
|
|
value = next;
|
|
return next;
|
|
},
|
|
refresh,
|
|
prefetch: () => {
|
|
void refresh();
|
|
},
|
|
reset: () => {
|
|
epoch += 1;
|
|
value = null;
|
|
inflight = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ChartSnapshotIdentity = {
|
|
accountId: string;
|
|
fingerprint: string;
|
|
subjectId?: string;
|
|
};
|
|
|
|
type ChartCacheSlot = {
|
|
identity: ChartSnapshotIdentity | null;
|
|
snapshot: ChartPageSnapshot;
|
|
};
|
|
|
|
let chartEpoch = 0;
|
|
let chartAccountReadGeneration = 0;
|
|
let pinnedChartIdentity: ChartSnapshotIdentity | null = null;
|
|
let chartSlot: ChartCacheSlot | null = null;
|
|
let chartInflight: { epoch: number; promise: Promise<ChartPageSnapshot> } | null = null;
|
|
|
|
function subjectOf(identity: { subjectId?: string } | null): string {
|
|
return identity?.subjectId || "self";
|
|
}
|
|
|
|
function sameChartIdentity(left: ChartSnapshotIdentity | null, right: ChartSnapshotIdentity | null): boolean {
|
|
return Boolean(
|
|
left && right
|
|
&& left.accountId === right.accountId
|
|
&& left.fingerprint === right.fingerprint
|
|
&& subjectOf(left) === subjectOf(right),
|
|
);
|
|
}
|
|
|
|
export function readChartSnapshotIdentity(snapshot: ChartPageSnapshot): ChartSnapshotIdentity | null {
|
|
if (snapshot.kind !== "view") return null;
|
|
const accountId = typeof snapshot.view.accountId === "string" ? snapshot.view.accountId : "";
|
|
const fingerprint = typeof snapshot.view.profileFingerprint === "string" ? snapshot.view.profileFingerprint : "";
|
|
if (!accountId || !fingerprint) return null;
|
|
return { accountId, fingerprint, subjectId: snapshot.view.subjectId || "self" };
|
|
}
|
|
|
|
function snapshotFromChartResult(result: { httpStatus: number; body: ChartViewResponse | null }): ChartPageSnapshot {
|
|
if (result.httpStatus === 401 || result.body?.status === "unauthenticated") return { kind: "unauthenticated" };
|
|
return {
|
|
kind: "view",
|
|
view: result.body ?? chartViewFailureResponse("response_schema_failure"),
|
|
};
|
|
}
|
|
|
|
function pinnedChartFailure(): ChartPageSnapshot {
|
|
const pin = pinnedChartIdentity;
|
|
return {
|
|
kind: "view",
|
|
view: {
|
|
...chartViewFailureResponse("response_schema_failure"),
|
|
...(pin ? { accountId: pin.accountId, profileFingerprint: pin.fingerprint } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function rememberChartSnapshot(snapshot: ChartPageSnapshot): ChartPageSnapshot {
|
|
const identity = readChartSnapshotIdentity(snapshot);
|
|
chartSlot = {
|
|
snapshot,
|
|
identity: identity ?? (pinnedChartIdentity && snapshot.kind === "view" ? { ...pinnedChartIdentity } : null),
|
|
};
|
|
return snapshot;
|
|
}
|
|
|
|
function chartSnapshotVisible(): ChartPageSnapshot | null {
|
|
if (!chartSlot) return null;
|
|
if (chartSlot.snapshot.kind === "unauthenticated") return chartSlot.snapshot;
|
|
if (!pinnedChartIdentity) return chartSlot.snapshot;
|
|
if (!chartSlot.identity) {
|
|
return chartSlot.snapshot.kind === "view" && chartSlot.snapshot.view.status !== "ok"
|
|
? chartSlot.snapshot
|
|
: null;
|
|
}
|
|
return chartSnapshotMatchesPin(chartSlot.snapshot) ? chartSlot.snapshot : null;
|
|
}
|
|
|
|
function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean {
|
|
if (!pinnedChartIdentity) return true;
|
|
if (snapshot.kind === "unauthenticated") return true;
|
|
const identity = readChartSnapshotIdentity(snapshot);
|
|
if (!identity) return snapshot.kind === "view" && snapshot.view.status !== "ok";
|
|
if (!pinnedChartIdentity.fingerprint) return subjectOf(identity) === subjectOf(pinnedChartIdentity);
|
|
return sameChartIdentity(identity, pinnedChartIdentity);
|
|
}
|
|
|
|
async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<ChartPageSnapshot> {
|
|
const subjectId = readCurrentSubjectId();
|
|
if (!pinnedChartIdentity || subjectOf(pinnedChartIdentity) !== subjectId) {
|
|
pinnedChartIdentity = {
|
|
accountId: pinnedChartIdentity?.accountId || "",
|
|
fingerprint: "",
|
|
subjectId,
|
|
};
|
|
chartSlot = null;
|
|
}
|
|
const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId }));
|
|
if (readCurrentSubjectId() !== subjectId) return refreshChartPage();
|
|
if (started !== chartEpoch) return refreshChartPage();
|
|
let snapshot = first;
|
|
if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) {
|
|
snapshot = snapshotFromChartResult(await fetchChartView({ signal, subjectId: readCurrentSubjectId() }));
|
|
if (started !== chartEpoch) return refreshChartPage();
|
|
}
|
|
if (!chartSnapshotMatchesPin(snapshot)) return rememberChartSnapshot(pinnedChartFailure());
|
|
const current = chartSnapshotVisible();
|
|
// A warm page can finish a requested layer while this D1-only refresh is
|
|
// still in flight. Its unrequested placeholders must not erase that layer.
|
|
if (snapshot.kind === "view" && snapshot.view.status === "ok"
|
|
&& current?.kind === "view" && current.view.status === "ok"
|
|
&& sameChartIdentity(readChartSnapshotIdentity(snapshot), readChartSnapshotIdentity(current))) {
|
|
const prior = current.view;
|
|
snapshot = { kind: "view", view: {
|
|
...snapshot.view,
|
|
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
|
|
dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
|
|
...(prior.western.status === "ok" ? { western: prior.western } : {}),
|
|
...(prior.qizheng.status === "ok" ? { qizheng: prior.qizheng } : {}),
|
|
} };
|
|
}
|
|
return rememberChartSnapshot(snapshot);
|
|
}
|
|
|
|
function refreshChartSnapshot(): Promise<ChartPageSnapshot> {
|
|
if (chartInflight && chartInflight.epoch === chartEpoch) return chartInflight.promise;
|
|
const started = chartEpoch;
|
|
// The shared request owns the deadline, including sidebar prefetch and the
|
|
// identity retry. A mounting hook cannot extend a hung prefetch's lifetime.
|
|
const signal = AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS);
|
|
const promise = loadChartSnapshot(started, signal).catch((error: unknown) => {
|
|
if (started !== chartEpoch) return refreshChartPage();
|
|
const cached = chartSnapshotVisible();
|
|
if (cached) return cached;
|
|
const status = chartClientFailureStatus(error);
|
|
console.warn("chart_page_fetch_failed", { status });
|
|
return rememberChartSnapshot({ kind: "view", view: status === "client_timeout"
|
|
? chartViewFailureResponse("client_timeout")
|
|
: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable },
|
|
});
|
|
}).finally(() => {
|
|
if (chartInflight?.promise === promise) chartInflight = null;
|
|
});
|
|
chartInflight = { epoch: started, promise };
|
|
return promise;
|
|
}
|
|
|
|
export function beginChartCacheAccountRead(): number {
|
|
return chartAccountReadGeneration;
|
|
}
|
|
|
|
export function accountChartPinStillCurrent(started: number): boolean {
|
|
return started === chartAccountReadGeneration;
|
|
}
|
|
|
|
export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void {
|
|
if (!identity.accountId || !identity.fingerprint) return;
|
|
if (sameChartIdentity(pinnedChartIdentity, identity)) return;
|
|
pinnedChartIdentity = { accountId: identity.accountId, fingerprint: identity.fingerprint };
|
|
chartEpoch += 1;
|
|
chartAccountReadGeneration += 1;
|
|
if (!sameChartIdentity(chartSlot?.identity ?? null, pinnedChartIdentity)) {
|
|
chartSlot = null;
|
|
}
|
|
}
|
|
|
|
let ephemerisSubject = "";
|
|
const ephemerisCache = createMemoryCache<EphemerisPageSnapshot>(async () => {
|
|
const subjectId = readCurrentSubjectId();
|
|
const response = await fetch(`/api/ephemeris?subject=${encodeURIComponent(subjectId)}`, {
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
cache: "no-store",
|
|
});
|
|
if (response.status === 401) {
|
|
ephemerisSubject = subjectId;
|
|
return { kind: "unauthorized" };
|
|
}
|
|
const json: unknown = await response.json().catch(() => null);
|
|
const payload = parseEphemerisOkResponse(json);
|
|
if (!payload) throw new Error("ephemeris_page_bad_payload");
|
|
if (readCurrentSubjectId() !== subjectId) throw new Error("ephemeris_subject_changed");
|
|
ephemerisSubject = subjectId;
|
|
return { kind: "ready", payload };
|
|
});
|
|
|
|
export function readReports(value: unknown): ReportListItem[] {
|
|
if (!value || typeof value !== "object" || !("reports" in value)) return [];
|
|
const reports = (value as { reports?: unknown }).reports;
|
|
if (!Array.isArray(reports)) return [];
|
|
return reports.flatMap((entry) => {
|
|
if (!entry || typeof entry !== "object") return [];
|
|
const row = entry as Record<string, unknown>;
|
|
if (typeof row.id !== "string" || !["generating", "ready", "failed"].includes(String(row.status))) {
|
|
return [];
|
|
}
|
|
return [{
|
|
id: row.id,
|
|
reportType: typeof row.reportType === "string" ? row.reportType : "personal_full",
|
|
presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default",
|
|
depth: typeof row.depth === "string" ? row.depth : "standard",
|
|
themes: Array.isArray(row.themes)
|
|
? row.themes.filter((theme): theme is string => typeof theme === "string")
|
|
: [],
|
|
status: row.status as ReportListItem["status"],
|
|
failureCode: typeof row.failureCode === "string" ? row.failureCode : null,
|
|
failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null,
|
|
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
|
|
completedAt: row.completedAt == null ? null : String(row.completedAt),
|
|
cardSummary: typeof row.cardSummary === "string" && row.cardSummary.trim()
|
|
? row.cardSummary
|
|
: null,
|
|
}];
|
|
});
|
|
}
|
|
|
|
let reportsSubject = "";
|
|
const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
|
|
const subjectId = readCurrentSubjectId();
|
|
const response = await fetch(`/api/reports?subject=${encodeURIComponent(subjectId)}`, {
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
cache: "no-store",
|
|
});
|
|
const json: unknown = await response.json().catch(() => null);
|
|
if (readCurrentSubjectId() !== subjectId) throw new Error("reports_subject_changed");
|
|
if (response.status === 401) {
|
|
reportsSubject = subjectId;
|
|
return { kind: "unauthorized" };
|
|
}
|
|
if (!response.ok) {
|
|
reportsSubject = subjectId;
|
|
return { kind: "error", reports: [] };
|
|
}
|
|
reportsSubject = subjectId;
|
|
return { kind: "ready", reports: readReports(json) };
|
|
});
|
|
|
|
export function peekChartPage(): ChartPageSnapshot | null {
|
|
return chartSnapshotVisible();
|
|
}
|
|
|
|
export function writeChartPage(snapshot: ChartPageSnapshot): ChartPageSnapshot {
|
|
if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) {
|
|
const current = chartSnapshotVisible();
|
|
if (current) return current;
|
|
return rememberChartSnapshot(pinnedChartFailure());
|
|
}
|
|
return rememberChartSnapshot(snapshot);
|
|
}
|
|
|
|
export function refreshChartPage(): Promise<ChartPageSnapshot> {
|
|
return refreshChartSnapshot();
|
|
}
|
|
|
|
export function peekEphemerisPage(): EphemerisPageSnapshot | null {
|
|
if (ephemerisSubject !== readCurrentSubjectId()) return null;
|
|
return ephemerisCache.peek();
|
|
}
|
|
|
|
export function writeEphemerisPage(snapshot: EphemerisPageSnapshot): EphemerisPageSnapshot {
|
|
if (snapshot.kind === "ready") ephemerisSubject = snapshot.payload.subjectId || readCurrentSubjectId();
|
|
return ephemerisCache.write(snapshot);
|
|
}
|
|
|
|
export function invalidateEphemerisPage(): void {
|
|
ephemerisSubject = "";
|
|
ephemerisCache.reset();
|
|
}
|
|
|
|
export function refreshEphemerisPage(): Promise<EphemerisPageSnapshot> {
|
|
return ephemerisCache.refresh();
|
|
}
|
|
|
|
export function peekReportsPage(): ReportsPageSnapshot | null {
|
|
if (reportsSubject !== readCurrentSubjectId()) return null;
|
|
return reportsCache.peek();
|
|
}
|
|
|
|
export function writeReportsPage(snapshot: ReportsPageSnapshot): ReportsPageSnapshot {
|
|
reportsSubject = readCurrentSubjectId();
|
|
return reportsCache.write(snapshot);
|
|
}
|
|
|
|
export function invalidateReportsPage(): void {
|
|
reportsSubject = "";
|
|
reportsCache.reset();
|
|
}
|
|
|
|
export function refreshReportsPage(): Promise<ReportsPageSnapshot> {
|
|
return reportsCache.refresh();
|
|
}
|
|
|
|
export function prefetchSecondaryPage(href: string): void {
|
|
if (!currentSubjectCatalogReady()) return;
|
|
if (href === "/chart" || href.startsWith("/chart?")) void refreshChartPage();
|
|
else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch();
|
|
else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch();
|
|
}
|
|
|
|
export function resetSecondaryPageDataForTests(): void {
|
|
chartEpoch += 1;
|
|
chartAccountReadGeneration += 1;
|
|
pinnedChartIdentity = null;
|
|
chartSlot = null;
|
|
chartInflight = null;
|
|
ephemerisSubject = "";
|
|
reportsSubject = "";
|
|
ephemerisCache.reset();
|
|
reportsCache.reset();
|
|
}
|