同一人的 Chara、Yogini 和以后的大运键各走各的缓存。页面日期直接用引擎返回的日子。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
|
|
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
|
|
const ENGINE_CACHE_MAX_ENTRIES = 200;
|
|
|
|
export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const;
|
|
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
|
|
|
|
/**
|
|
* On-demand dasha columns. A new `/api/dasha` system is one row here plus a
|
|
* mapper. Chara keeps its own path, so its cache scope stays empty and the
|
|
* historical key does not move. Every `/api/dasha` row must set `cacheScope`,
|
|
* because those calls share one path.
|
|
*/
|
|
export const CHART_DASHA_SYSTEMS = [
|
|
{ layer: "chara", dasha: "chara", cacheScope: null, track: "chara", path: "/api/dasha/chara" },
|
|
{ layer: "yogini", dasha: "yogini", cacheScope: "dasha:yogini", track: "yogini", path: "/api/dasha" },
|
|
] as const;
|
|
|
|
export type ChartDashaSystem = (typeof CHART_DASHA_SYSTEMS)[number];
|
|
|
|
export function dashaCacheScope(dashaKey: string): string {
|
|
return `dasha:${dashaKey}`;
|
|
}
|
|
|
|
export function dashaSystemByLayer(layer: string): ChartDashaSystem | null {
|
|
return CHART_DASHA_SYSTEMS.find((item) => item.layer === layer) ?? null;
|
|
}
|
|
|
|
export type EngineCallResult =
|
|
| { readonly status: "ok"; readonly payload: Record<string, unknown> }
|
|
| {
|
|
readonly status: "busy" | "http_error" | "timeout" | "bad_payload";
|
|
readonly path: string;
|
|
readonly elapsedMs: number;
|
|
readonly httpStatus?: number;
|
|
readonly errorName?: string;
|
|
};
|
|
|
|
type CacheStore = Map<string, { payload: Record<string, unknown>; expiresAt: number }>;
|
|
|
|
const state = globalThis as typeof globalThis & {
|
|
jyotishaChartViewEngineCache?: CacheStore;
|
|
};
|
|
|
|
function engineCache(): CacheStore {
|
|
state.jyotishaChartViewEngineCache ??= new Map();
|
|
return state.jyotishaChartViewEngineCache;
|
|
}
|
|
|
|
export function parseChartViewLayers(raw: string | null | undefined): ChartViewLayer[] {
|
|
if (!raw) return [];
|
|
const wanted = new Set<ChartViewLayer>();
|
|
for (const part of raw.split(",")) {
|
|
const id = part.trim();
|
|
if ((CHART_VIEW_LAYERS as readonly string[]).includes(id)) {
|
|
wanted.add(id as ChartViewLayer);
|
|
}
|
|
}
|
|
return CHART_VIEW_LAYERS.filter((id) => wanted.has(id));
|
|
}
|
|
|
|
export function chartViewEngineCacheKey(input: {
|
|
userId: string;
|
|
subjectId: string;
|
|
date: string;
|
|
time: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
timezoneOffset: number;
|
|
ayanamsa: string;
|
|
nodeMode: string;
|
|
path: string;
|
|
}): string {
|
|
return [
|
|
input.userId,
|
|
input.subjectId,
|
|
input.path,
|
|
input.date,
|
|
input.time,
|
|
input.latitude.toFixed(6),
|
|
input.longitude.toFixed(6),
|
|
String(input.timezoneOffset),
|
|
input.ayanamsa,
|
|
input.nodeMode,
|
|
].join("\u001f");
|
|
}
|
|
|
|
/**
|
|
* The path slot of the cache key. A call whose body is not the birth moment
|
|
* (the transit chart posts `/api/chart` for a date) carries a scope, so it can
|
|
* never be answered with the cached natal chart or with another day's sky.
|
|
*/
|
|
export function chartViewEngineCachePath(path: string, cacheScope?: string): string {
|
|
return cacheScope ? `${path}@${cacheScope}` : path;
|
|
}
|
|
|
|
export function readChartViewEngineCache(key: string, now = Date.now()): Record<string, unknown> | null {
|
|
const entry = engineCache().get(key);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt <= now) {
|
|
engineCache().delete(key);
|
|
return null;
|
|
}
|
|
return entry.payload;
|
|
}
|
|
|
|
export function writeChartViewEngineCache(
|
|
key: string,
|
|
payload: Record<string, unknown>,
|
|
now = Date.now(),
|
|
): void {
|
|
const store = engineCache();
|
|
if (store.size >= ENGINE_CACHE_MAX_ENTRIES) {
|
|
for (const [itemKey, entry] of store) {
|
|
if (entry.expiresAt <= now) store.delete(itemKey);
|
|
}
|
|
if (store.size >= ENGINE_CACHE_MAX_ENTRIES) {
|
|
const first = store.keys().next().value;
|
|
if (typeof first === "string") store.delete(first);
|
|
}
|
|
}
|
|
store.set(key, { payload, expiresAt: now + CHART_VIEW_ENGINE_CACHE_TTL_MS });
|
|
}
|
|
|
|
export function clearChartViewEngineCache(): void {
|
|
engineCache().clear();
|
|
}
|
|
|
|
export function engineCallFromHttp(input: {
|
|
path: string;
|
|
elapsedMs: number;
|
|
httpStatus: number;
|
|
payload: unknown;
|
|
}): EngineCallResult {
|
|
if (input.httpStatus === 429) {
|
|
return {
|
|
status: "busy",
|
|
path: input.path,
|
|
elapsedMs: input.elapsedMs,
|
|
httpStatus: input.httpStatus,
|
|
};
|
|
}
|
|
if (input.httpStatus < 200 || input.httpStatus >= 300) {
|
|
return {
|
|
status: "http_error",
|
|
path: input.path,
|
|
elapsedMs: input.elapsedMs,
|
|
httpStatus: input.httpStatus,
|
|
};
|
|
}
|
|
if (!input.payload || typeof input.payload !== "object" || Array.isArray(input.payload)) {
|
|
return {
|
|
status: "bad_payload",
|
|
path: input.path,
|
|
elapsedMs: input.elapsedMs,
|
|
httpStatus: input.httpStatus,
|
|
};
|
|
}
|
|
return { status: "ok", payload: input.payload as Record<string, unknown> };
|
|
}
|
|
|
|
export function engineCallFromThrown(path: string, elapsedMs: number, error: unknown): EngineCallResult {
|
|
const errorName = error instanceof Error ? error.name : "Error";
|
|
const status = errorName === "TimeoutError" || errorName === "AbortError" ? "timeout" : "http_error";
|
|
return { status, path, elapsedMs, errorName };
|
|
}
|
|
|
|
export function logEngineCallFailure(result: Exclude<EngineCallResult, { status: "ok" }>): void {
|
|
console.warn("chart_view_engine_failed", {
|
|
path: result.path,
|
|
status: result.status,
|
|
httpStatus: result.httpStatus ?? null,
|
|
errorName: result.errorName ?? null,
|
|
elapsedMs: result.elapsedMs,
|
|
});
|
|
}
|
|
|
|
export function enginePayload(result: EngineCallResult): Record<string, unknown> | null {
|
|
return result.status === "ok" ? result.payload : null;
|
|
}
|