// Live progress, wired into the query cache. One place, because the stream and the reads answer // the same questions and two copies of the answer would drift on the first busy run. // // The stream WRITES into the cache rather than into a state of its own: server data has exactly one // home (FRONTEND_PLAN.md rule 5), and a component that read "progress from the stream, everything // else from the query" would be the second home. import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { bookScope, keys, subscribeToRun } from '../api'; import type { BookDetail, ChapterList, ConnectionState, Frame, Id } from '../api'; export function useRunStream(bookId: Id | undefined, runId: Id | undefined): ConnectionState { const client = useQueryClient(); const [state, setState] = useState('connecting'); // ⚠ The run id travels INSIDE the book card, and a resync empties that card for the length of one // read — so for that moment the caller has no id to give, and the subscription would be torn down // and opened again over a gap in the data that merely DESCRIBES it. Measured, not reasoned: // with an answer that takes 120 ms the stream opened twice (the acceptance of S3.7 asked for // exactly this measurement, and the earlier claim that it does not happen was taken against an // instant mock). // // Held per BOOK: another book is another stream, and there the id must not survive. Legal by the // contract as well — `run` is "the current or last run", so once a book has one it never goes // back to none. // State and not a ref, although a ref is what this looks like: a ref may not be read while // rendering (`react-hooks/refs`), and the value is needed to decide what to render. const [held, setHeld] = useState<{ bookId?: Id; runId?: Id }>({}); if (held.bookId !== bookId) { setHeld({ bookId, runId }); // The state belongs to the stream it was measured on. Left alone, the first frame painted for // another book carried the previous book's verdict — a status strip saying "live progress is // unavailable" about a connection that had not been opened yet. setState('connecting'); } else if (runId !== undefined && runId !== held.runId) setHeld({ bookId, runId }); const streamId = runId ?? held.runId; useEffect(() => { if (bookId === undefined || streamId === undefined) return; const runId = streamId; // Keys whose re-read is already on its way. Per subscription rather than per module: it dies // with the stream it belongs to, and nothing leaks into the next book. const asked = new Set(); return subscribeToRun({ bookId, runId, onState: setState, onFrame: (frame, revision) => { apply(frame, revision, bookId, client, asked); }, onResync: () => { // The marks go with the snapshots. They say "a re-read of this key is already on its way", // and after a full replacement that sentence is about an epoch that no longer exists — a // frame arriving in the window right after a resync would be eaten by a mark left from // before it (acceptance of S3.7). asked.clear(); // Replaying history is forbidden by the contract, so the only correct answer to "I cannot // resume you" is to read the snapshots again. // // RESET, not invalidate: a resync follows a FULL replacement, where the revision is not // promised to keep growing. Invalidating kept the old snapshots, and with them the marks // every later read is compared against — the new epoch's lower numbers were then dropped // as stale, and the screen stayed on data that no longer exists. The stream side already // forgets its own mark; this is the same move on the cache side. Scoped to the book: the // library, the usage and a second book were not replaced. void client.resetQueries(bookScope(bookId)); }, }); }, [bookId, streamId, client]); // Without a run there is no channel, and the answer says so rather than keeping the last word of // a stream nobody holds any more — the status strip would go on claiming a live connection // (named by the adversarial review). Derived, not stored: state written from an effect is a // cascade of renders, and there is nothing here to store that the arguments do not already say. return bookId === undefined || streamId === undefined ? 'connecting' : state; } type Client = ReturnType; function apply( frame: Frame, revision: number, bookId: Id, client: Client, asked: Set, ): void { switch (frame.event) { case 'progress': patch(client, keys.book(bookId), revision, asked, (detail) => ({ ...detail, book: { ...detail.book, progress: frame.data.progress }, })); return; case 'status': patch(client, keys.book(bookId), revision, asked, (detail) => ({ ...detail, book: { ...detail.book, status: frame.data.status }, run: detail.run ? { ...detail.run, status: frame.data.status, paused_reason: frame.data.paused_reason } : detail.run, })); // A stage boundary or a stop is exactly where the translated text is written (contract, // `Unit`), and an open chapter is on a keep-alive tab that never unmounts — nothing else // would ever ask for it again. Whole-book scope on purpose: while `units_done` stays a // single counter (K-10) a chapter frame may not arrive once in a whole draft wave. rereadAll(client, keys.bookUnits(bookId), asked); // The chapter LIST goes stale at the same boundary and for the same reason (the acceptance of // S3.7 turned Ф-49's own premise back on it): every row of it carries `units_done` and // `note_count`, a stage boundary moves them all, and the per-chapter frame that would patch // them may not arrive once in a whole wave while `units_done` stays a single counter (K-10). reread(client, keys.chapters(bookId), asked); // And the LIBRARY, which carries the same status in its own list: the tree's badge is the // main carrier of the state (a word, not a dot), and it went on saying "queued" while the run // it names was already halted — seen on the frame of the ceiling stop. Re-read and never // patched: the library is a scope of its own with a revision of its own, and stamping it with // the book's number would be comparing two counters that are not comparable (contract, // `Revision`). reread(client, keys.library(), asked); return; case 'chapter': patch(client, keys.chapters(bookId), revision, asked, (list) => ({ ...list, chapters: list.chapters.map((chapter) => chapter.id === frame.data.chapter_id ? { ...chapter, units_done: frame.data.units_done, note_count: frame.data.note_count } : chapter, ), })); // The frame carries counters, not text: "the live signal arrives as an event; the text // arrives with a read after the boundary". Scoped to the chapter the frame names — re-reading // every open tab on every frame would cost a read per tab per burst. reread(client, keys.units(bookId, frame.data.chapter_id), asked); return; // A note is one-shot and a bank change replaces rows wholesale: neither is a delta this client // can apply, so both are answered by re-reading rather than by patching. case 'note': reread(client, keys.notes(bookId), asked); return; case 'bank': reread(client, keys.bank(bookId), asked); return; // The ceiling halt is a status change the client must not guess at: it re-reads the book and // shows whatever the platform now says, including the machine reason for the pause. case 'ceiling': reread(client, keys.book(bookId), asked); return; case 'hello': case 'resync_required': return; } } /** * Asks a key to answer again. EVERY frame goes through here, whether it patches something or only * says "this changed" — because both vanish in the same window. * * ⚠ Reset when the entry is EMPTY, invalidate when it holds something, and the difference is not a * nicety: a query with no data ignores an invalidation. The library restarts an in-flight fetch * only when data is already there (`Query.fetch`: `state.data !== undefined && cancelRefetch`); * otherwise it hands back the promise of the very read that is stale — the read prepared BEFORE the * frame, which is what the frame's own mark has already been counted against. * * The mark keeps that to ONE restart while the entry stays empty. Measured, not assumed: a burst of * five frames on an empty entry cost six reads without it and two with it, and the server MAY * coalesce, so bursts are the normal shape of a run. What the restarted read misses of a burst the * next frame patches, because by then the entry is no longer empty. * * ⚠ The mark lives exactly as long as the read it stands for, and not a moment longer. Set and left * behind — which is what it was — it says "on its way" about a read that ENDED: a read that failed * leaves the entry empty, so every later frame for that key found the mark and returned, and the * key was silenced for the rest of the run (acceptance of S3.7). A reset that starts no read at all * — a key nobody is watching — resolves at once and clears it just the same. */ function reread(client: Client, key: readonly unknown[], asked: Set): void { const mark = key.join('/'); if (client.getQueryData(key) !== undefined) { asked.delete(mark); void client.invalidateQueries({ queryKey: key }); return; } if (asked.has(mark)) return; asked.add(mark); void client.resetQueries({ queryKey: key }).finally(() => asked.delete(mark)); } /** * The same, for a PREFIX that stands for several entries — every chapter of a book. Asked of the * cache one exact key at a time rather than of the prefix as a whole: a prefix has no entry of its * own, so it would look empty and reset every open chapter at once, blanking readers that were * showing text a moment ago. */ function rereadAll(client: Client, prefix: readonly unknown[], asked: Set): void { for (const query of client.getQueryCache().findAll({ queryKey: prefix })) { reread(client, query.queryKey, asked); } } /** * Applies one frame to one cache entry, and moves that entry's revision with it: the revision is * what a late read is compared against, and without it the next refetch on focus overwrites the * live numbers with older ones. * * A frame that finds NO entry has nothing to patch, so the only trace it can leave is a re-read. */ function patch( client: Client, key: readonly unknown[], revision: number, asked: Set, change: (held: T) => T, ): void { const held = client.getQueryData(key); if (held === undefined) { reread(client, key, asked); return; } asked.delete(key.join('/')); client.setQueryData(key, { ...change(held), revision }); }