test: pin that retiring a game cannot delete a reader's own events
Nothing in the client prunes reader data today — no store filters against the feed, knownGames only appends, metaFor renders a lane whose game is gone — so a retired source, page or game leaves completion marks, streaks and hand-entered events untouched. That was true by construction and pinned nowhere. The load path is what makes it worth pinning. useCustom reads through validRecords, which drops a record that fails its schema, and the survivors are what the next write persists. A record that stops parsing is therefore not hidden until someone notices — it is deleted from the device, permanently, by the act of opening the app, with no server-side copy to recover it from. Which makes CustomEvent.game being z.string() rather than GameId the whole safety property, and it currently reads like validation someone forgot. Anyone narrowing it to the enum would be tightening a schema and arming every future game removal to erase reader data on next launch. The tests state the premise, the survival, and that one unreadable neighbour still does not take the rest down; the field says why it is loose. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acadbfa17a
commit
239e970471
@@ -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
|
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)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 711 tests
|
test/ 716 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
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
|
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.
|
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
|
## Shipping a new version
|
||||||
|
|
||||||
The shell is cached cache-first, so a reader with the tab open keeps the bundle they first loaded.
|
The shell is cached cache-first, so a reader with the tab open keeps the bundle they first loaded.
|
||||||
|
|||||||
+13
-1
@@ -69,7 +69,19 @@ export type CustomGame = z.infer<typeof CustomGame>;
|
|||||||
export const CustomEvent = z
|
export const CustomEvent = z
|
||||||
.object({
|
.object({
|
||||||
id: CustomEventId,
|
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),
|
game: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
type: EventType,
|
type: EventType,
|
||||||
|
|||||||
@@ -336,3 +336,64 @@ describe("validRecords — the import gate", () => {
|
|||||||
expect(kept).toEqual({});
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user