Ensure lastConfirmedAt never precedes contentChangedAt for fetched snapshots and calculate game-level lastConfirmedAt using effective pull times.
269 lines
11 KiB
TypeScript
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);
|
|
}
|