Files
gacha-event-tracker/src/shared/feed.ts
T
Lucas Winther 157b9fe20c fix(feed): reflect content updates in source health confirmation and freshness
Ensure lastConfirmedAt never precedes contentChangedAt for fetched snapshots and calculate game-level lastConfirmedAt using effective pull times.
2026-09-18 09:30:31 +02:00

269 lines
11 KiB
TypeScript

import { z } from "zod";
import { GachaEvent, GameId } from "./schema.ts";
/**
* The wire contract between server and client.
*
* The client refuses a `schemaVersion` it does not know rather than guessing at
* unfamiliar fields. Additive fields do not bump it; removing or retyping one
* does. See docs/DATA-MODEL.md § Schema versioning.
*/
export const SCHEMA_VERSION = 1;
export const SourceHealth = z.object({
sourceId: z.string(),
game: GameId,
url: z.string().url(),
lastSuccessAt: z.string().datetime().nullable(),
/**
* When the crawler last successfully contacted and confirmed this source
* (HTTP 200 or 304 Not Modified). Null when never checked (e.g. clean checkout
* using fixtures, or older feed).
*/
lastConfirmedAt: z.string().datetime().nullable().default(null),
/**
* When the served bytes last changed.
*/
contentChangedAt: z.string().datetime().nullable().default(null),
/** Events this source contributed to the feed — after expired ones are dropped. */
eventCount: z.number().int().nonnegative(),
/**
* Events the document yields when parsed as of its own capture date, before
* anything is dropped for having ended.
*
* The pair is what separates a broken source from a stale one. `eventCount`
* alone cannot: a parser that has stopped reading a redesigned page and a
* page whose every event has since finished both report zero, and only the
* first means our code is wrong.
*
* **Nullable and defaulted, never required.** The client validates the whole
* feed with `EventFeed.safeParse`, and the service worker serves the last
* feed it downloaded — so a required field here would make every cached feed
* fail validation and take the offline promise with it. Null means an older
* feed that never recorded this, which is an absence of information rather
* than evidence of a fault.
*/
parsedCount: z.number().int().nonnegative().nullable().default(null),
/**
* The page itself says it currently lists no events.
*
* The third of the three ways a source can read zero, and the only one the
* feed could not previously express. A redesigned page the parser can no
* longer read, a page whose events have all ended, and a page printing
* "There are no Events in this category" all arrive as `parsedCount: 0` —
* and the last one is a source *answering*, not failing.
*
* `scripts/refresh-sources.ts` has drawn this distinction since 2026-09-03
* and the feed did not, so a correctly quiet lane reddened CI every build.
* Carried here because `brokenSources` runs against the feed and has nothing
* else to go on: only the parser has seen the page.
*
* **Set from the page's own words, never from a row count** — a redesign
* yields zero rows too, and excusing *that* is the silently emptied calendar
* the check exists for. Defaulted rather than required, for the reason
* `parsedCount` is: an older cached feed must keep validating.
*/
statesNoEvents: z.boolean().default(false),
});
export const EventFeed = z.object({
schemaVersion: z.literal(SCHEMA_VERSION),
generatedAt: z.string().datetime(),
events: z.array(GachaEvent),
sources: z.array(SourceHealth),
});
export type SourceHealth = z.infer<typeof SourceHealth>;
export type EventFeed = z.infer<typeof EventFeed>;
/** A game's data is stale past this age (PRD F7). */
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
export interface StaleSourceInfo {
sourceId: string;
url: string;
lastSuccessAt: string | null;
lastConfirmedAt: string | null;
contentChangedAt: string | null;
}
export interface StaleGame {
game: GameId;
lastSuccessAt: string | null;
lastConfirmedAt: string | null;
contentChangedAt: string | null;
sources: StaleSourceInfo[];
}
export interface Freshness {
/**
* When any source last had its bytes confirmed — the newest `lastSuccessAt`.
*
* Deliberately not `generatedAt`. The feed is rebuilt on every deploy whether
* or not a page was refetched, so a build stamp would report a calendar as
* minutes old while its events came from a fixture captured months ago. This
* reports the age of the *data*, which is the only thing a reader is trusting
* (PRD F7: never present stale data as current).
*
* Null only when no source has ever succeeded, which is a fresh checkout with
* no fixtures — not a state a reader reaches.
*/
refreshedAt: string | null;
/** Per game, oldest first: what has not refreshed inside `STALE_AFTER_MS`. */
stale: StaleGame[];
}
/**
* How current this feed's data is, per game.
*
* Pure and clock-injected like everything else that a test needs to pin. One
* game can have several sources, and a game is only as fresh as its *oldest*
* one: if Endfield's wiki refreshed an hour ago but its Game8 page has been
* down for a week, some of that lane's rows are a week old and saying "fresh"
* would be the confident wrong answer this product exists to avoid.
*/
export function freshness(
sources: readonly SourceHealth[],
now: number,
): Freshness {
const oldestPerGame = new Map<GameId, string | null>();
const sourcesByGame = new Map<GameId, SourceHealth[]>();
let refreshedAt: string | null = null;
for (const source of sources) {
const at = source.lastSuccessAt;
if (at !== null && (refreshedAt === null || at > refreshedAt)) {
refreshedAt = at;
}
// `null` beats any date: a source that has never succeeded is the oldest
// thing a game can have, and must not be outvoted by a sibling that has.
// `undefined` is the separate case of no entry yet, which is why this reads
// the map once rather than asking `has` and then `get`.
const known = oldestPerGame.get(source.game);
if (known === undefined || (known !== null && (at === null || at < known))) {
oldestPerGame.set(source.game, at);
}
const list = sourcesByGame.get(source.game) ?? [];
list.push(source);
sourcesByGame.set(source.game, list);
}
const stale: StaleGame[] = [...oldestPerGame.entries()]
.filter(([, at]) => at === null || now - Date.parse(at) > STALE_AFTER_MS)
.map(([game, lastSuccessAt]) => {
const gameSources = sourcesByGame.get(game) ?? [];
const sourcesInfo: StaleSourceInfo[] = gameSources
.map((s) => ({
sourceId: s.sourceId,
url: s.url,
lastSuccessAt: s.lastSuccessAt,
lastConfirmedAt: s.lastConfirmedAt ?? null,
contentChangedAt: s.contentChangedAt ?? s.lastSuccessAt,
}))
.sort((a, b) => a.sourceId.localeCompare(b.sourceId));
let lastConfirmedAt: string | null = null;
let hasConfirmed = false;
for (const s of sourcesInfo) {
const pullAt =
s.lastConfirmedAt !== null &&
s.contentChangedAt !== null &&
Date.parse(s.lastConfirmedAt) >= Date.parse(s.contentChangedAt)
? s.lastConfirmedAt
: (s.lastConfirmedAt ??
(s.contentChangedAt && now - Date.parse(s.contentChangedAt) <= STALE_AFTER_MS
? s.contentChangedAt
: null));
if (!hasConfirmed) {
lastConfirmedAt = pullAt;
hasConfirmed = true;
} else if (lastConfirmedAt !== null && (pullAt === null || pullAt < lastConfirmedAt)) {
lastConfirmedAt = pullAt;
}
}
let contentChangedAt: string | null = null;
let hasContent = false;
for (const s of sourcesInfo) {
if (!hasContent) {
contentChangedAt = s.contentChangedAt;
hasContent = true;
} else if (contentChangedAt !== null && (s.contentChangedAt === null || s.contentChangedAt < contentChangedAt)) {
contentChangedAt = s.contentChangedAt;
}
}
return {
game,
lastSuccessAt,
lastConfirmedAt,
contentChangedAt,
sources: sourcesInfo,
};
})
.sort((a, b) => (a.lastSuccessAt ?? "").localeCompare(b.lastSuccessAt ?? ""));
return { refreshedAt, stale };
}
/**
* Sources whose document yielded nothing at all.
*
* This is the failure a parser-only pipeline is most prone to and that nothing
* else would surface: a page is redesigned, the parser reads it as empty, and
* one game's calendar goes blank while the total stays comfortably healthy.
* Worth failing a build over.
*
* A source whose events have merely all ended is not this, and CI said it was
* — the check read `eventCount`, which is measured after expiry, so a stale
* page and a broken parser arrived as the same zero. Only an explicit zero
* counts here; a null is an older feed that never recorded the figure, and
* failing on missing information would be the same mistake in a new place.
*
* Nor is a page that states its own emptiness, which is that same mistake a
* third time: a gacha calendar goes quiet between versions, and Infinity
* Nikki's wiki says so in words. `statesNoEvents` is the page answering, so it
* is excused here exactly as the refresh runner already excuses it — see that
* field, and `scripts/refresh-sources.ts`.
*/
export function brokenSources(sources: readonly SourceHealth[]): SourceHealth[] {
return sources.filter((s) => s.parsedCount === 0 && !s.statesNoEvents);
}
/**
* Sources that parsed fine but have nothing current left to show.
*
* A real problem — that lane renders an empty calendar — but a refresh
* problem rather than a code one, and some of these cannot be refreshed from
* CI at all (`docs/SOURCES.md` records which hosts refuse the runner). So it
* is reported and left visible rather than thrown, the same way the app shows
* a stale timestamp rather than pretending the calendar is current.
*/
export function staleSources(sources: readonly SourceHealth[]): SourceHealth[] {
return sources.filter(
(s) => s.parsedCount !== null && s.parsedCount > 0 && s.eventCount === 0,
);
}
/**
* Sources whose page says it currently lists no events.
*
* The third empty, and the only one that is neither a fault nor a gap: a gacha
* calendar goes quiet between versions and this page said so in words. Not
* thrown, for the reason `statesNoEvents` exists at all.
*
* Reported, though, and that is the half worth defending. Excusing this zero
* from the build is not the same as saying nothing about it — the build log
* prints a count per source, and an unexplained `0` reads as exactly the fault
* this distinction denies. It is also the only thing that would ever prompt
* somebody to check whether a lane has been quiet for a month because the game
* is between patches or because the page's wording changed under a
* `statesNoEvents` that still matches.
*/
export function quietSources(sources: readonly SourceHealth[]): SourceHealth[] {
return sources.filter((s) => s.statesNoEvents);
}