diff --git a/AGENTS.md b/AGENTS.md index cb45eec..a58753d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ src/client/ React app, service worker, manifest theme.ts — dark or light, and what a game hue reads as on each scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 711 tests +test/ 716 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` @@ -631,6 +631,17 @@ A lane may now be a game the reader invented, so `gameMeta` is a context resolve and total) rather than a direct lookup — a lane can outlive its game when an import carries an event whose game did not come with it. +**Retiring a game, a source or a page must never cost the reader a row they typed.** We retire +things routinely — a source moves, a page goes stale, a game shuts down — and their events are the +only copy in existence. Nothing in the client deletes: no store prunes against the feed, `knownGames` +only ever appends, and `metaFor` renders a lane whose game is gone rather than dropping it. The one +place this could break is the load path. `useCustom` reads through `validRecords`, which **drops a +record that fails its schema**, and the survivors are what the next write persists — so a record that +stops parsing is not hidden pending a fix, it is deleted from the device by the act of opening the +app. That is why `CustomEvent.game` is `z.string()` and not `GameId`: narrowing it to the enum reads +like a tightening and would arm every future game removal to erase reader data on next launch. +`test/custom.test.ts` § retiring a game, a source or a page pins it. + ## Shipping a new version The shell is cached cache-first, so a reader with the tab open keeps the bundle they first loaded. diff --git a/src/shared/custom.ts b/src/shared/custom.ts index d5698be..05c7260 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -69,7 +69,19 @@ export type CustomGame = z.infer; export const CustomEvent = z .object({ id: CustomEventId, - /** A tracked game, or one of theirs — a source can miss an event too. */ + /** + * A tracked game, or one of theirs — a source can miss an event too. + * + * **`z.string()` and not `GameId`, deliberately.** We retire games, sources + * and pages routinely, and this field is the only thing standing between + * that and a reader losing a row they typed: `useCustom` reads through + * `validRecords`, which drops a record that fails this schema, and the + * survivors are what the next write persists. Narrowing this to the enum + * would read as a tightening and would arm every future removal to erase + * reader data on next launch — silently, with no server-side recovery, + * exactly as § Event IDs are localStorage keys describes for `slugify`. + * `metaFor` is total so an id with no game behind it still renders. + */ game: z.string().min(1), title: z.string().min(1).max(200), type: EventType, diff --git a/test/custom.test.ts b/test/custom.test.ts index e8dbcf9..de3ff3a 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -336,3 +336,64 @@ describe("validRecords — the import gate", () => { expect(kept).toEqual({}); }); }); + +describe("retiring a game, a source or a page", () => { + /** + * The reader's own events are the only copy that exists — no server has ever + * seen them (AGENTS.md § Three constraints). We retire things routinely: a + * source moves, a page goes stale, a game shuts down. None of that may cost + * them a row they typed. + * + * The load path is what makes this sharp rather than theoretical. + * `useCustom` reads through `validRecords`, which **drops** a record that + * fails its schema, and the surviving set is what the next write persists. + * So a record that stops parsing is not hidden until someone fixes it — it is + * deleted from the device, permanently, by the act of opening the app. + * + * `CustomEvent.game` is therefore `z.string()` and not `GameId`, which reads + * like missing validation and is the load-bearing decision here. Narrowing it + * to the enum would look like a tightening and would arm every future game + * removal to erase reader data on next launch. + */ + const retired = "a-game-we-no-longer-track"; + + test("a game id we retire is not one the enum still holds", () => { + // Guards the premise: if this ever became a real id the test below stops + // testing anything. + expect(GameId.options).not.toContain(retired as never); + }); + + test("keeps an event the reader filed under a game we later dropped", () => { + const orphaned = { ...ownEvent(), game: retired }; + // Parses on its own... + expect(CustomEvent.safeParse(orphaned).success).toBe(true); + // ...and survives the gate that decides what stays on the device. + const kept = validRecords({ [orphaned.id]: orphaned }, CustomEvent); + expect(kept[orphaned.id]).toBeDefined(); + expect(kept[orphaned.id]?.game).toBe(retired); + }); + + test("keeps the neighbours of an event that genuinely is unreadable", () => { + // The drop is per record and always has been; this pins that a retired + // lane is not what triggers it, and that one bad row is not contagious. + const good = ownEvent(); + const kept = validRecords( + { [good.id]: good, "myevent:broken": { id: "myevent:broken" } }, + CustomEvent, + ); + expect(Object.keys(kept)).toEqual([good.id]); + }); + + test("still names the lane, so a retired game shows a row rather than nothing", () => { + const meta = metaFor(retired, {}); + expect(meta.name).toBe("Unknown game"); + expect(meta.hue).toMatch(/^#[0-9A-Fa-f]{6}$/); + }); + + test("still clocks it, so the countdown does not need the game to exist", () => { + const orphaned = CustomEvent.parse({ ...ownEvent(), game: retired }); + const c = clockFor(asDisplayEvent(orphaned), "europe", Date.parse("2026-08-25T00:00:00.000Z")); + expect(c.live).toBe(true); + expect(c.endsMs).not.toBeNull(); + }); +});