// The reads and the actions, one function per contract operation. Plain async functions plus plain // option objects: the query layer is React's, and `src/api/` stays free of React so that the seam // can be tested and replaced without a renderer. // // Every read goes through the same two steps in the same order — follow the cursor to the end of // the collection, then narrow the vocabularies, then let the revision guard drop a stale answer. import { replaceEqualDeep } from '@tanstack/react-query'; import { request, requestAll } from './client'; import { narrow } from './contract'; import { bookStatus } from './vocabulary'; import type { Bank, BankDecision, BookDetail, BookStatus, ChapterList, Id, Library, NoteList, Run, RunOptions, UnitList, Usage, } from './contract'; import type { components } from './schema'; type Schemas = components['schemas']; /** * Discarding a stale read is the CLIENT's duty (contract, `Revision`), and this is where it is done * — against what the application HAS, not against a copy of it. * * The counter is per book, and the stream carries the same counter: a frame raises the revision of * the cache entry it patches, so a read prepared before that frame and delivered after it arrives * with a LOWER number and is dropped here. Comparing against a private copy of the last read * instead — which is what this code did first — never saw the frame at all, and the interface * rolled progress backwards on every refetch on focus. The query layer already holds the previous * value; a second store of it was both a leak and a lie. */ export function freshest(previous: T | undefined, next: T): T { return previous !== undefined && next.revision < previous.revision ? previous : next; } // Applied by the query layer to every read below. // // ⚠ COMPOSED with the library's own structural sharing, not substituted for it. `structuralSharing` // is a single slot: a function there REPLACES `replaceEqualDeep`, and every read rebuilds its rows // (`rows.map(narrow.*)`), so an ordinary refetch would hand the cache a wholly new object graph. // The lists this feeds are the 2284-chapter tree and the 1200-term bank, which rebuild their whole // collection on a new identity — a full rebuild on every window focus, where the library would have // produced no render at all. const dropStaleReads = { structuralSharing: (previous: unknown, next: unknown) => { const kept = freshest( previous as { revision: number } | undefined, next as { revision: number }, ); return kept === next ? replaceEqualDeep(previous, next) : kept; }, }; /** * How often a book still in intake is asked about again, in milliseconds. * * ⚠ POLLING, and it is the contract's shape that forces it rather than a shortcut: the live channel * belongs to a RUN (`/runs/{runId}/events`), and a book being uploaded or parsed has no run — so * nothing pushes "the parsing is over". Without asking again, a book stays `parsing` on the screen * until something else happens to invalidate the read. The interval is deliberately unhurried: this * is a step that takes minutes on a real book, and the answer is a small JSON. It stops the moment * the book leaves intake — a poll that never stops is the thing to be afraid of here (BACKLOG Ф-56: * an event for the end of the intake is a question for the contract's owner). */ export const intakePollMs = 3000; /** Whether a book is still arriving. The answer is a property of the STATUS and lives with it. */ export const inIntake = (status: BookStatus | null | undefined) => status !== undefined && bookStatus.describe(status).intake === true; export const keys = { library: () => ['library'] as const, book: (bookId: Id) => ['book', bookId] as const, chapters: (bookId: Id) => ['chapters', bookId] as const, units: (bookId: Id, chapterId: Id) => ['units', bookId, chapterId] as const, /** The text of EVERY chapter of one book — the scope a stage boundary makes stale. */ bookUnits: (bookId: Id) => ['units', bookId] as const, notes: (bookId: Id) => ['notes', bookId] as const, bank: (bookId: Id) => ['bank', bookId] as const, usage: () => ['usage'] as const, runOptions: (bookId: Id) => ['run-options', bookId] as const, }; /** * Everything read ABOUT one book. The book id stands second in every book-scoped key and nowhere * else, so a resync can throw away one book's world without touching the library, the usage or a * second book — which a keyless `invalidateQueries()` did. */ export const bookScope = (bookId: Id) => ({ predicate: (query: { queryKey: readonly unknown[] }) => query.queryKey[1] === bookId, }); export async function readLibrary(): Promise { const { page, rows } = await requestAll( '/books', (body) => body.books, ); return { ...page, books: rows.map(narrow.book) }; } export async function readBook(bookId: Id): Promise { const wire = await request(`/books/${encodeURIComponent(bookId)}`); return { ...wire, book: narrow.book(wire.book), run: wire.run ? narrow.run(wire.run) : wire.run }; } export async function readChapters(bookId: Id): Promise { const { page, rows } = await requestAll( `/books/${encodeURIComponent(bookId)}/chapters`, (body) => body.chapters, ); return { ...page, chapters: rows }; } export async function readUnits(bookId: Id, chapterId: Id): Promise { const { page, rows } = await requestAll( `/books/${encodeURIComponent(bookId)}/chapters/${encodeURIComponent(chapterId)}/units`, (body) => body.units, ); return { ...page, units: rows.map(narrow.unit) }; } export async function readNotes(bookId: Id): Promise { const { page, rows } = await requestAll( `/books/${encodeURIComponent(bookId)}/notes`, (body) => body.notes, ); return { ...page, notes: rows.map(narrow.note) }; } export async function readBank(bookId: Id): Promise { let counts = { total: 0, signed: 0 }; const { page, rows } = await requestAll( `/books/${encodeURIComponent(bookId)}/bank`, (body) => { // Both counts are of the whole bank, not of the page, so the last page's numbers are the // book's numbers — a sum across pages would multiply them. counts = { total: body.total, signed: body.signed }; return body.terms; }, ); return { ...page, ...counts, terms: rows.map(narrow.term) }; } export async function readUsage(): Promise { return narrow.usage(await request('/usage')); } export async function readRunOptions(bookId: Id): Promise { return request(`/books/${encodeURIComponent(bookId)}/run-options`); } /** * Option objects for the query layer. Plain data, so the screen writes `useQuery(libraryQuery())` * and the key never gets paired with the wrong function in two different screens. */ export const libraryQuery = () => ({ queryKey: keys.library(), queryFn: readLibrary, ...dropStaleReads, refetchInterval: ({ state }: { state: { data?: Library } }) => state.data?.books.some((book) => inIntake(book.status)) === true ? intakePollMs : false, }); export const bookQuery = (bookId: Id) => ({ queryKey: keys.book(bookId), queryFn: () => readBook(bookId), ...dropStaleReads, refetchInterval: ({ state }: { state: { data?: BookDetail } }) => inIntake(state.data?.book.status) ? intakePollMs : false, }); export const chaptersQuery = (bookId: Id) => ({ queryKey: keys.chapters(bookId), queryFn: () => readChapters(bookId), ...dropStaleReads, }); export const unitsQuery = (bookId: Id, chapterId: Id) => ({ queryKey: keys.units(bookId, chapterId), queryFn: () => readUnits(bookId, chapterId), ...dropStaleReads, }); export const notesQuery = (bookId: Id) => ({ queryKey: keys.notes(bookId), queryFn: () => readNotes(bookId), ...dropStaleReads, }); export const bankQuery = (bookId: Id) => ({ queryKey: keys.bank(bookId), queryFn: () => readBank(bookId), ...dropStaleReads, }); export const usageQuery = () => ({ queryKey: keys.usage(), queryFn: readUsage }); export const runOptionsQuery = (bookId: Id) => ({ queryKey: keys.runOptions(bookId), queryFn: () => readRunOptions(bookId), // ⚠ NEVER fresh from the cache, against the client's own 15-second default: the whole reason // these bounds are a resource of their own is that the maximum belongs to the ACCOUNT and moves // while the book does not (contract, `run-options`). Re-opening the form within the window // otherwise showed a scale read before somebody else's hold was taken. staleTime: 0, }); // Actions. Unsafe methods, so every one of them carries `X-TM-Client` — the client half of the // CSRF rule, applied by `request()` and not by each caller. export async function startRun(bookId: Id, body: Schemas['RunRequest']): Promise { return narrow.run( await request(`/books/${encodeURIComponent(bookId)}/runs`, { method: 'POST', body, }), ); } export async function stopRun(runId: Id): Promise { return narrow.run( await request(`/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST' }), ); } export async function resumeRun(runId: Id): Promise { return narrow.run( await request(`/runs/${encodeURIComponent(runId)}/resume`, { method: 'POST' }), ); } export async function submitBankDecisions( bookId: Id, decisions: BankDecision[], ): Promise { return request( `/books/${encodeURIComponent(bookId)}/bank/decisions`, { method: 'POST', body: { decisions } }, ); }