243 lines
9.7 KiB
TypeScript
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 } },
|
|
);
|
|
}
|