textmachine/frontend/src/api/queries.ts

243 lines
9.7 KiB
TypeScript

// 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<T extends { revision: number }>(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<Library> {
const { page, rows } = await requestAll<Schemas['Book'], Schemas['Library']>(
'/books',
(body) => body.books,
);
return { ...page, books: rows.map(narrow.book) };
}
export async function readBook(bookId: Id): Promise<BookDetail> {
const wire = await request<Schemas['BookDetail']>(`/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<ChapterList> {
const { page, rows } = await requestAll<Schemas['Chapter'], Schemas['ChapterList']>(
`/books/${encodeURIComponent(bookId)}/chapters`,
(body) => body.chapters,
);
return { ...page, chapters: rows };
}
export async function readUnits(bookId: Id, chapterId: Id): Promise<UnitList> {
const { page, rows } = await requestAll<Schemas['Unit'], Schemas['UnitList']>(
`/books/${encodeURIComponent(bookId)}/chapters/${encodeURIComponent(chapterId)}/units`,
(body) => body.units,
);
return { ...page, units: rows.map(narrow.unit) };
}
export async function readNotes(bookId: Id): Promise<NoteList> {
const { page, rows } = await requestAll<Schemas['Note'], Schemas['NoteList']>(
`/books/${encodeURIComponent(bookId)}/notes`,
(body) => body.notes,
);
return { ...page, notes: rows.map(narrow.note) };
}
export async function readBank(bookId: Id): Promise<Bank> {
let counts = { total: 0, signed: 0 };
const { page, rows } = await requestAll<Schemas['BankTerm'], Schemas['Bank']>(
`/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<Usage> {
return narrow.usage(await request<Schemas['Usage']>('/usage'));
}
export async function readRunOptions(bookId: Id): Promise<RunOptions> {
return request<RunOptions>(`/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<Run> {
return narrow.run(
await request<Schemas['Run']>(`/books/${encodeURIComponent(bookId)}/runs`, {
method: 'POST',
body,
}),
);
}
export async function stopRun(runId: Id): Promise<Run> {
return narrow.run(
await request<Schemas['Run']>(`/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST' }),
);
}
export async function resumeRun(runId: Id): Promise<Run> {
return narrow.run(
await request<Schemas['Run']>(`/runs/${encodeURIComponent(runId)}/resume`, { method: 'POST' }),
);
}
export async function submitBankDecisions(
bookId: Id,
decisions: BankDecision[],
): Promise<Schemas['BankDecisionsResult']> {
return request<Schemas['BankDecisionsResult']>(
`/books/${encodeURIComponent(bookId)}/bank/decisions`,
{ method: 'POST', body: { decisions } },
);
}