Files
jrshikoku/lib/officialPositionCache.ts
harukin-expo-dev-env 1ecd026908 feat: Implement official position metadata handling and caching
- Add `currentTrainMetadata.ts` to manage carrying forward official position metadata between train snapshots.
- Introduce `lineDisplayName.ts` for mapping line IDs to display names.
- Create `officialPositionCache.ts` for parsing and serializing official position data with cache validation.
- Enhance `officialPositionService.ts` to support persistent caching of official positions and improve resolver functionality.
- Update `requestCoordinator.ts` to allow cancellation of polling requests without disposing of the coordinator.
- Modify `trainTimeFiltering.ts` to incorporate line ID context when checking for duplicate train data.
- Refactor `useCurrentTrain.tsx` to support polling for current train data with official position enrichment.
- Add tests for new features, including persistent cache behavior and handling of ambiguous train data.
2026-09-15 13:48:21 +09:00

77 lines
2.6 KiB
TypeScript

import type { OfficialPosition } from "@/lib/domain/railway";
const CACHE_VERSION = 1;
const MAX_CACHE_ENTRIES = 1000;
type CacheRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is CacheRecord =>
typeof value === "object" && value !== null;
const isOfficialPosition = (value: unknown): value is OfficialPosition => {
if (!isRecord(value)) return false;
return (
typeof value.positionId === "string" &&
value.positionId.length > 0 &&
typeof value.pos === "string" &&
value.pos.length > 0 &&
typeof value.line === "string" &&
value.line.length > 0 &&
typeof value.posNum === "string" &&
value.posNum.length > 0 &&
["unclassified", "station", "between", "prediction", "other"].includes(
value.positionType as string,
) &&
(value.platform === null || typeof value.platform === "string") &&
(value.track === null || typeof value.track === "string") &&
(value.note === null || typeof value.note === "string") &&
["unreviewed", "partial", "reviewed"].includes(
value.enrichmentStatus as string,
) &&
(value.firstSeenAt === null || typeof value.firstSeenAt === "string") &&
(value.lastSeenAt === null || typeof value.lastSeenAt === "string") &&
typeof value.revision === "number" &&
Number.isInteger(value.revision) &&
value.revision >= 0
);
};
/**
* Parse the local warm cache without trusting its contents. Invalid or old
* entries are ignored so a broken cache never blocks the live resolver.
*/
export const parseOfficialPositionCache = (
raw: unknown,
): Map<string, OfficialPosition> => {
let value = raw;
if (typeof value === "string") {
try {
value = JSON.parse(value) as unknown;
} catch {
return new Map();
}
}
if (!isRecord(value) || value.version !== CACHE_VERSION) return new Map();
if (!Array.isArray(value.entries)) return new Map();
const result = new Map<string, OfficialPosition>();
for (const entry of value.entries.slice(-MAX_CACHE_ENTRIES)) {
if (!isRecord(entry)) continue;
if (typeof entry.key !== "string" || !entry.key) continue;
if (!isOfficialPosition(entry.value)) continue;
result.set(entry.key, entry.value);
}
return result;
};
/** Serialize only the bounded, validated metadata cache. */
export const serializeOfficialPositionCache = (
entries: ReadonlyMap<string, OfficialPosition>,
): string => {
const serialized = Array.from(entries.entries())
.filter(([, value]) => isOfficialPosition(value))
.slice(-MAX_CACHE_ENTRIES)
.map(([key, value]) => ({ key, value }));
return JSON.stringify({ version: CACHE_VERSION, entries: serialized });
};