From 2e4f36f5c5f90d29e9d8114a421449d02cd3bc36 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 3 Jun 2026 18:51:29 -0600 Subject: [PATCH] feat(pdf): introduce parser versioning and playback readiness state Add PDF_PARSER_VERSION constant and propagate parser versioning throughout the PDF parsing, job, and API layers. Implement normalization of parse state to ensure compatibility with the current parser version, and enable reuse of parsed PDF results when possible. Add isPlaybackReady state to document hooks and TTS player, improving playback UX by disabling controls until content is ready. Update tests to reflect new playback readiness logic. BREAKING CHANGE: PDF parse state and job logic now require explicit parserVersion; older parse states may be treated as pending until reprocessed. --- compute/core/src/index.ts | 1 + compute/core/src/pdf/parse.ts | 3 +- compute/core/src/pdf/parser-version.ts | 1 + compute/core/src/pdf/pdfjs-runtime.ts | 5 +- src/app/(app)/epub/[id]/page.tsx | 3 +- src/app/(app)/epub/[id]/useEpubDocument.ts | 8 ++ src/app/(app)/html/[id]/page.tsx | 3 +- src/app/(app)/html/[id]/useHtmlDocument.ts | 22 ++++- src/app/(app)/pdf/[id]/page.tsx | 3 +- src/app/(app)/pdf/[id]/usePdfDocument.ts | 21 ++++- .../api/documents/[id]/parsed/events/route.ts | 5 +- src/app/api/documents/[id]/parsed/route.ts | 23 +++-- .../api/documents/docx-to-pdf/upload/route.ts | 15 +++- src/app/api/documents/route.ts | 29 +++++-- src/components/player/TTSPlayer.tsx | 13 +-- src/contexts/TTSContext.tsx | 33 +++++++- src/lib/server/compute/worker-parse-state.ts | 2 + src/lib/server/compute/worker.ts | 3 +- .../server/documents/parse-state-healing.ts | 1 + src/lib/server/documents/parse-state.ts | 32 ++++++- src/lib/server/documents/parsed-pdf-reuse.ts | 33 ++++++++ src/lib/server/jobs/user-pdf-layout-job.ts | 71 +++++++++++++++- tests/helpers.ts | 7 +- tests/navigation.spec.ts | 6 +- tests/unit/pdf-op-key.vitest.spec.ts | 13 +++ tests/unit/pdf-parse-state.vitest.spec.ts | 84 +++++++++++++++++++ tests/unit/worker-parse-state.vitest.spec.ts | 6 +- 27 files changed, 398 insertions(+), 48 deletions(-) create mode 100644 compute/core/src/pdf/parser-version.ts create mode 100644 src/lib/server/documents/parsed-pdf-reuse.ts create mode 100644 tests/unit/pdf-op-key.vitest.spec.ts create mode 100644 tests/unit/pdf-parse-state.vitest.spec.ts diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index 4ba1e89..4360a58 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -16,6 +16,7 @@ export { } from './config/timeout'; export { renderPage } from './pdf/render'; export { mergeTextWithRegions } from './pdf/merge'; +export { PDF_PARSER_VERSION } from './pdf/parser-version'; export { stitchCrossPageBlocks } from './pdf/stitch'; export { normalizeTextItemsForLayout } from './pdf/normalize-text'; export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map'; diff --git a/compute/core/src/pdf/parse.ts b/compute/core/src/pdf/parse.ts index 4cf0cd3..f0bde04 100644 --- a/compute/core/src/pdf/parse.ts +++ b/compute/core/src/pdf/parse.ts @@ -4,6 +4,7 @@ import { ensureModel } from './model'; import { runLayoutModel } from './runLayoutModel'; import { mergeTextWithRegions } from './merge'; import { resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime'; +import { PDF_PARSER_VERSION } from './parser-version'; import { stitchCrossPageBlocks } from './stitch'; import { renderPage } from './render'; import { normalizeTextItemsForLayout } from './normalize-text'; @@ -143,7 +144,7 @@ export async function parsePdf(input: ParsePdfInput): Promise const doc: ParsedPdfDocument = { schemaVersion: 1, documentId: input.documentId, - parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69', + parserVersion: PDF_PARSER_VERSION, parsedAt: Date.now(), pages, }; diff --git a/compute/core/src/pdf/parser-version.ts b/compute/core/src/pdf/parser-version.ts new file mode 100644 index 0000000..b5fa7d7 --- /dev/null +++ b/compute/core/src/pdf/parser-version.ts @@ -0,0 +1 @@ +export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69'; diff --git a/compute/core/src/pdf/pdfjs-runtime.ts b/compute/core/src/pdf/pdfjs-runtime.ts index 206e650..233d9c3 100644 --- a/compute/core/src/pdf/pdfjs-runtime.ts +++ b/compute/core/src/pdf/pdfjs-runtime.ts @@ -1,12 +1,15 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; let cachedStandardFontDataUrl: string | null = null; +const require = createRequire(import.meta.url); export function resolvePdfjsStandardFontDataUrl(): string { if (cachedStandardFontDataUrl) return cachedStandardFontDataUrl; - const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const pdfjsPackageDir = path.dirname(require.resolve('pdfjs-dist/package.json')); + const standardFontDir = path.join(pdfjsPackageDir, 'standard_fonts'); if (!fs.existsSync(standardFontDir)) { throw new Error(`pdfjs-dist standard_fonts directory not found at ${standardFontDir}`); diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 4a25c2e..b08d4d4 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -31,6 +31,7 @@ export default function EPUBPage() { const { setCurrentDocument, currDocName, + isPlaybackReady, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, @@ -242,7 +243,7 @@ export default function EPUBPage() { ) : ( - + )} Promise; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; @@ -106,6 +107,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); // Mirror state into a ref so resolveEpubLocator (registered once with // TTSContext via a stable callback) can always read the latest page text // without forcing re-registration on every page turn. @@ -160,6 +162,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { setCurrDocData(undefined); setCurrDocName(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); setCurrDocPages(undefined); isEPUBSetOnce.current = false; shouldPauseRef.current = true; @@ -179,6 +182,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { + setIsPlaybackReady(false); const meta = await getDocumentMetadata(id); if (!meta) { clearCurrDoc(); @@ -215,6 +219,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { */ const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise => { try { + setIsPlaybackReady(false); const location = rendition?.location; if (!location) return ''; const { start, end } = location; @@ -244,6 +249,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { nextText: continuationPreview }); setCurrDocText(textContent); + setIsPlaybackReady(true); return textContent; } catch (error) { @@ -390,6 +396,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, @@ -415,6 +422,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index f0193a3..09a0b45 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -31,6 +31,7 @@ export default function HTMLPage() { setCurrentDocument, currDocData, currDocName, + isPlaybackReady, blocks, isTxt, clearCurrDoc, @@ -233,7 +234,7 @@ export default function HTMLPage() { ) : ( - + )} Promise; @@ -73,6 +74,7 @@ export function useHtmlDocument(): HtmlDocumentState { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); const isTxt = useMemo(() => isTxtName(currDocName), [currDocName]); const blocks = useMemo( @@ -91,22 +93,36 @@ export function useHtmlDocument(): HtmlDocumentState { // sentence splitting + sequential advancement from there. const lastFedDocRef = useRef(null); useEffect(() => { - if (!currDocText) return; + if (currDocData === undefined) { + setIsPlaybackReady(false); + return; + } + if (!currDocText) { + setIsPlaybackReady(true); + return; + } const key = `${currDocData ?? ''}::${currDocText.length}`; - if (lastFedDocRef.current === key) return; + if (lastFedDocRef.current === key) { + setIsPlaybackReady(true); + return; + } + setIsPlaybackReady(false); lastFedDocRef.current = key; setTTSText(currDocText); + setIsPlaybackReady(true); }, [currDocText, currDocData, setTTSText]); const clearCurrDoc = useCallback(() => { setCurrDocData(undefined); setCurrDocName(undefined); + setIsPlaybackReady(false); lastFedDocRef.current = null; stop(); }, [stop]); const setCurrentDocument = useCallback(async (id: string): Promise => { try { + setIsPlaybackReady(false); const meta = await getDocumentMetadata(id); if (!meta) { console.error('Document not found on server'); @@ -207,6 +223,7 @@ export function useHtmlDocument(): HtmlDocumentState { currDocData, currDocName, currDocText, + isPlaybackReady, blocks, isTxt, setCurrentDocument, @@ -218,6 +235,7 @@ export function useHtmlDocument(): HtmlDocumentState { currDocData, currDocName, currDocText, + isPlaybackReady, blocks, isTxt, setCurrentDocument, diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 79d4060..6cfe8cf 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -52,6 +52,7 @@ export default function PDFViewerPage() { clearCurrDoc, currDocPage, currDocPages, + isPlaybackReady, parseStatus, parseProgress, documentSettings, @@ -487,7 +488,7 @@ export default function PDFViewerPage() { ) : isParseReady ? ( - + ) : null} (); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); const [pdfDocument, setPdfDocument] = useState(); const [parsedDocument, setParsedDocument] = useState(null); const [parseStatus, setParseStatus] = useState(null); @@ -169,6 +171,7 @@ export function usePdfDocument(): PdfDocumentState { const docLoadAbortRef = useRef(null); const parsePollAbortRef = useRef(null); const parseSseCloseRef = useRef<(() => void) | null>(null); + const lastPreparedPlaybackPageRef = useRef(null); const fetchParsedDocument = useCallback(async ( documentId: string, @@ -248,6 +251,8 @@ export function usePdfDocument(): PdfDocumentState { if (snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed') { if (snapshot.parseStatus === 'failed') { setParsedDocument(null); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setActiveParseOpId(null); } else { void fetchParsedDocument( @@ -297,6 +302,7 @@ export function usePdfDocument(): PdfDocumentState { useEffect(() => { setCurrDocPage(currDocPageNumber); + setIsPlaybackReady(false); }, [currDocPageNumber]); /** @@ -324,11 +330,13 @@ export function usePdfDocument(): PdfDocumentState { if (!currentPdf) return; const seq = ++loadSeqRef.current; const pageNumber = currDocPageNumber; + setIsPlaybackReady(false); const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => parsedDocument?.pages.find((page) => page.pageNumber === pageNum); if (parseStatus !== 'ready' || !parsedDocument) { + lastPreparedPlaybackPageRef.current = null; setCurrDocText(undefined); setTTSText('', { location: currDocPageNumber }); return; @@ -401,7 +409,8 @@ export function usePdfDocument(): PdfDocumentState { return; } - if (text !== currDocText || text === '') { + const shouldPreparePlayback = text === '' || text !== currDocText || lastPreparedPlaybackPageRef.current !== currDocPageNumber; + if (shouldPreparePlayback) { setCurrDocText(text); const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { @@ -414,6 +423,8 @@ export function usePdfDocument(): PdfDocumentState { ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } + lastPreparedPlaybackPageRef.current = currDocPageNumber; + setIsPlaybackReady(true); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return; @@ -469,6 +480,8 @@ export function usePdfDocument(): PdfDocumentState { setPdfDocument(undefined); setCurrDocPages(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); @@ -546,6 +559,8 @@ export function usePdfDocument(): PdfDocumentState { pageTextCacheRef.current.clear(); setParsedDocument(null); setCurrDocText(undefined); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setParseStatus(forced.status); setParseProgress(null); setActiveParseOpId(forced.opId ?? null); @@ -575,6 +590,7 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocName(undefined); setCurrDocData(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); setCurrDocPages(undefined); setPdfDocument(undefined); setParsedDocument(null); @@ -582,6 +598,7 @@ export function usePdfDocument(): PdfDocumentState { setParseProgress(null); setActiveParseOpId(null); setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); + lastPreparedPlaybackPageRef.current = null; pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); @@ -681,6 +698,7 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, parsedDocument, parseStatus, parseProgress, @@ -708,6 +726,7 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, parsedDocument, parseStatus, parseProgress, diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index e1adc9d..79d6801 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -9,6 +9,7 @@ import { isWorkerOperationStateStale, snapshotFromWorkerState } from '@/lib/serv import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, stringifyDocumentParseState, @@ -67,7 +68,7 @@ async function toSnapshotState(row: ParseRow, preferredOpId?: string | null): Pr const state = await healStaleDocumentParseState({ documentId: row.id, userId: row.userId, - state: parseDocumentParseState(row.parseState), + state: normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)), }); const parseStatus = normalizeParseStatus(state.status); const dbOpId = typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null; @@ -199,7 +200,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string let initialState = await toSnapshotState(row, requestedOpId); if (!requestedOpId && !initialState.opId && initialState.snapshot.parseStatus !== 'ready' && initialState.snapshot.parseStatus !== 'failed') { - const state = parseDocumentParseState(row.parseState); + const state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); const created = await backfillPendingPdfParseOperation({ documentId: id, userId: row.userId, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 650760c..11158ea 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -20,8 +20,10 @@ import { putParsedDocumentBlob, } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, + resolveParsedPdfParserVersion, stringifyDocumentParseState, } from '@/lib/server/documents/parse-state'; import { backfillPendingPdfParseOperation } from '@/lib/server/documents/parse-state-backfill'; @@ -156,13 +158,6 @@ async function finalizeFromWorkerState(input: { }); } - await writeParseRowState({ - documentId: input.row.id, - userId: input.row.userId, - parseState: stringifyDocumentParseState(workerParseState), - parsedJsonKey, - }); - const json = await getParsedDocumentBlobByKey(parsedJsonKey); let parsedDoc: ParsedPdfDocument | null = null; try { @@ -171,6 +166,16 @@ async function finalizeFromWorkerState(input: { parsedDoc = null; } + await writeParseRowState({ + documentId: input.row.id, + userId: input.row.userId, + parseState: stringifyDocumentParseState({ + ...workerParseState, + parserVersion: resolveParsedPdfParserVersion(parsedDoc), + }), + parsedJsonKey, + }); + if (!hasAnyParsedBlocks(parsedDoc)) { input.logger.warn({ event: 'documents.parsed.no_blocks_in_output', @@ -246,7 +251,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }); } - let state = parseDocumentParseState(row.parseState); + let state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); state = await healStaleDocumentParseState({ documentId: id, userId: row.userId, @@ -409,7 +414,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - let state = parseDocumentParseState(row.parseState); + let state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); state = await healStaleDocumentParseState({ documentId: id, userId: row.userId, diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 7ba5623..10d1f23 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -19,6 +19,7 @@ import { isS3Configured } from '@/lib/server/storage/s3'; import { putDocumentBlob } from '@/lib/server/documents/blobstore'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); @@ -142,7 +143,12 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, - parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }), parsedJsonKey: null, }) .onConflictDoUpdate({ @@ -153,7 +159,12 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, - parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }), parsedJsonKey: null, }, }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 903ab96..7ea9f40 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -17,12 +17,15 @@ import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, stringifyDocumentParseState, } from '@/lib/server/documents/parse-state'; +import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { BaseDocument, DocumentType } from '@/types/documents'; export const dynamic = 'force-dynamic'; @@ -100,6 +103,9 @@ export async function POST(req: NextRequest) { for (const doc of documentsData) { let headSize = doc.size; + const reusableParsedPdf = doc.type === 'pdf' + ? await findReusableParsedPdfResult(doc.id) + : null; // Retry HEAD check to handle S3 read-after-write propagation delays. // The client uploads bytes directly to S3 via presigned URL, then @@ -146,9 +152,13 @@ export async function POST(req: NextRequest) { lastModified: doc.lastModified, filePath: doc.id, parseState: doc.type === 'pdf' - ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + ? stringifyDocumentParseState( + reusableParsedPdf + ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } + : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, + ) : null, - parsedJsonKey: null, + parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -159,9 +169,13 @@ export async function POST(req: NextRequest) { lastModified: doc.lastModified, filePath: doc.id, parseState: doc.type === 'pdf' - ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + ? stringifyDocumentParseState( + reusableParsedPdf + ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } + : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, + ) : null, - parsedJsonKey: null, + parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, }, }); @@ -191,7 +205,7 @@ export async function POST(req: NextRequest) { }, 'Failed to enqueue document preview'); }); - if (doc.type === 'pdf') { + if (doc.type === 'pdf' && !reusableParsedPdf) { // Account for upload-driven parse load in the same ledger the explicit // re-parse limiter reads. We record (not reject) here so a legitimate // bulk upload always parses; the recorded load still throttles @@ -260,13 +274,16 @@ export async function GET(req: NextRequest) { const results: BaseDocument[] = rows.map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); + const parseState = type === 'pdf' + ? normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(doc.parseState)) + : null; return { id: doc.id, name: doc.name, size: Number(doc.size), lastModified: Number(doc.lastModified), type, - parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null, + parseStatus: type === 'pdf' && parseState ? normalizeParseStatus(parseState.status) : null, parsedJsonKey: doc.parsedJsonKey, scope: 'user', }; diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index 1badd9b..a93ddbd 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -13,9 +13,10 @@ import { SpeedControl } from '@/components/player/SpeedControl'; import { Navigator } from '@/components/player/Navigator'; import { IconButton } from '@/components/ui'; -export default function TTSPlayer({ currentPage, numPages }: { +export default function TTSPlayer({ currentPage, numPages, isPlaybackReady = true }: { currentPage?: number; numPages?: number | undefined; + isPlaybackReady?: boolean; }) { const { isPlaying, @@ -52,7 +53,7 @@ export default function TTSPlayer({ currentPage, numPages }: { {isProcessing ? : } @@ -61,16 +62,18 @@ export default function TTSPlayer({ currentPage, numPages }: { - {isPlaying ? : } + {!isPlaying && !isPlaybackReady + ? + : (isPlaying ? : )} {isProcessing ? : } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index f1e0e9e..b346baf 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -624,6 +624,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [currentWordIndex, setCurrentWordIndex] = useState(null); const isPlayingRef = useRef(false); const pauseEpochRef = useRef(0); + const pendingPlaybackStartRef = useRef(false); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); const plannedSegmentsByLocationRef = useRef>(new Map()); @@ -939,6 +940,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const recordManualPause = useCallback(() => { // Cancel any queued auto-resume intent and mark an explicit user pause. resumeAfterLocationChangeRef.current = false; + pendingPlaybackStartRef.current = false; pauseEpochRef.current += 1; }, []); @@ -947,6 +949,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * Used for external control of playback state */ const pause = useCallback(() => { + pendingPlaybackStartRef.current = false; recordManualPause(); clearPendingEpubJump(); abortPendingTtsRequests(true); @@ -1248,10 +1251,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const shouldPause = normalizedOptions.shouldPause ?? false; const pauseEpochAtStart = pauseEpochRef.current; const pendingAutoResume = resumeAfterLocationChangeRef.current; - const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume); - if (shouldPause || pendingAutoResume) { - resumeAfterLocationChangeRef.current = false; - } + const pendingPlaybackStart = pendingPlaybackStartRef.current; + const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume || pendingPlaybackStart); // Keep track of previous state and pause playback invalidatePlaybackRun(); @@ -1262,10 +1263,23 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement try { if (newSentences.length === 0) { console.warn('No sentences found in text'); + if (shouldPause || pendingAutoResume) { + resumeAfterLocationChangeRef.current = false; + } + if (pendingPlaybackStart) { + pendingPlaybackStartRef.current = false; + } setIsProcessing(false); return; } + if (shouldPause || pendingAutoResume) { + resumeAfterLocationChangeRef.current = false; + } + if (pendingPlaybackStart) { + pendingPlaybackStartRef.current = false; + } + if (!isEPUB && typeof resolvedLocation === 'number') { const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); if (firstFingerprint) { @@ -1388,6 +1402,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Ensure audio is unlocked while we're still in the click/tap handler. unlockPlaybackOnUserGesture(); + const hasPreparedSentence = Boolean(playbackSegments[currentIndex]?.text ?? sentences[currentIndex]); + if (!activeHowl && !hasPreparedSentence) { + pendingPlaybackStartRef.current = true; + setIsProcessing(true); + return; + } + // Resume current sentence if we already have a paused Howl. if (activeHowl) { applyPlaybackRateToHowl(activeHowl); @@ -1406,12 +1427,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setIsPlaying(true); }, [ activeHowl, + currentIndex, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, + playbackSegments, recordManualPause, clearPendingEpubJump, abortPendingTtsRequests, + sentences, unlockPlaybackOnUserGesture, ]); @@ -2878,6 +2902,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio(); clearWarmAudioCache(); playbackInFlightRef.current = false; + pendingPlaybackStartRef.current = false; pendingJumpTargetRef.current = null; clearPendingEpubJump(); bumpEpubPreloadGeneration(); diff --git a/src/lib/server/compute/worker-parse-state.ts b/src/lib/server/compute/worker-parse-state.ts index cce75a4..6798a22 100644 --- a/src/lib/server/compute/worker-parse-state.ts +++ b/src/lib/server/compute/worker-parse-state.ts @@ -1,3 +1,4 @@ +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { DocumentParseState } from '@/lib/server/documents/parse-state'; @@ -42,6 +43,7 @@ export function documentParseStateFromWorkerState( ? parseProgress : null, updatedAt: nowMs, + parserVersion: PDF_PARSER_VERSION, ...(typeof state.opId === 'string' && state.opId.trim() ? { opId: state.opId } : {}), ...(typeof state.jobId === 'string' && state.jobId.trim() ? { jobId: state.jobId } : {}), ...(parseStatus === 'failed' && state.error?.message ? { error: state.error.message } : {}), diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 6f94501..cdc79cc 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; -import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core'; +import { getWorkerClientWaitTimeoutMs, PDF_PARSER_VERSION } from '@openreader/compute-core'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import type { @@ -190,6 +190,7 @@ export function buildPdfOpKey(input: PdfLayoutInput): string { return [ 'pdf_layout', 'v1', + PDF_PARSER_VERSION, input.documentId, input.namespace ?? '', input.documentObjectKey ?? '', diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts index 82ca5dc..d8fcbd4 100644 --- a/src/lib/server/documents/parse-state-healing.ts +++ b/src/lib/server/documents/parse-state-healing.ts @@ -24,6 +24,7 @@ export async function healStaleDocumentParseState(input: { error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`, ...(input.state.opId ? { opId: input.state.opId } : {}), ...(input.state.jobId ? { jobId: input.state.jobId } : {}), + ...(input.state.parserVersion ? { parserVersion: input.state.parserVersion } : {}), })); await db diff --git a/src/lib/server/documents/parse-state.ts b/src/lib/server/documents/parse-state.ts index 431e20c..22a94ad 100644 --- a/src/lib/server/documents/parse-state.ts +++ b/src/lib/server/documents/parse-state.ts @@ -1,4 +1,5 @@ -import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import type { ParsedPdfDocument, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; export interface DocumentParseState { status: PdfParseStatus; @@ -7,6 +8,7 @@ export interface DocumentParseState { error?: string | null; opId?: string; jobId?: string; + parserVersion?: string; } export function isInProgressParseStatus(status: PdfParseStatus): status is 'pending' | 'running' { @@ -32,6 +34,31 @@ export function normalizeParseStatus(status: string | null | undefined): PdfPars return 'pending'; } +export function hasCurrentPdfParserVersion(parserVersion: string | null | undefined): boolean { + return typeof parserVersion === 'string' && parserVersion.trim() === PDF_PARSER_VERSION; +} + +export function normalizeDocumentParseStateForCurrentParserVersion( + state: DocumentParseState, + nowMs = Date.now(), +): DocumentParseState { + const status = normalizeParseStatus(state.status); + if (status === 'failed' || hasCurrentPdfParserVersion(state.parserVersion)) { + return state; + } + + return { + status: 'pending', + progress: null, + updatedAt: nowMs, + }; +} + +export function resolveParsedPdfParserVersion(parsed: ParsedPdfDocument | null | undefined): string { + const parserVersion = parsed?.parserVersion?.trim(); + return parserVersion || PDF_PARSER_VERSION; +} + function normalizeProgress(progress: unknown): PdfParseProgress | null { if (!progress || typeof progress !== 'object') return null; const rec = progress as Record; @@ -63,6 +90,7 @@ export function parseDocumentParseState(value: string | null): DocumentParseStat const error = typeof parsed.error === 'string' ? parsed.error : null; const opId = typeof parsed.opId === 'string' ? parsed.opId : undefined; const jobId = typeof parsed.jobId === 'string' ? parsed.jobId : undefined; + const parserVersion = typeof parsed.parserVersion === 'string' ? parsed.parserVersion : undefined; return { status, progress, @@ -70,6 +98,7 @@ export function parseDocumentParseState(value: string | null): DocumentParseStat ...(error ? { error } : {}), ...(opId ? { opId } : {}), ...(jobId ? { jobId } : {}), + ...(parserVersion ? { parserVersion } : {}), }; } catch { return { status: 'pending', progress: null }; @@ -84,5 +113,6 @@ export function stringifyDocumentParseState(state: DocumentParseState): string { ...(state.error ? { error: state.error } : {}), ...(state.opId ? { opId: state.opId } : {}), ...(state.jobId ? { jobId: state.jobId } : {}), + ...(state.parserVersion ? { parserVersion: state.parserVersion } : {}), }); } diff --git a/src/lib/server/documents/parsed-pdf-reuse.ts b/src/lib/server/documents/parsed-pdf-reuse.ts new file mode 100644 index 0000000..1bbede4 --- /dev/null +++ b/src/lib/server/documents/parsed-pdf-reuse.ts @@ -0,0 +1,33 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { + normalizeDocumentParseStateForCurrentParserVersion, + parseDocumentParseState, +} from '@/lib/server/documents/parse-state'; + +type ReadyParsedPdfResult = { + parsedJsonKey: string; +}; + +export async function findReusableParsedPdfResult( + documentId: string, +): Promise { + const rows = await db + .select({ + parseState: documents.parseState, + parsedJsonKey: documents.parsedJsonKey, + }) + .from(documents) + .where(eq(documents.id, documentId)); + + for (const row of rows) { + const parsedJsonKey = row.parsedJsonKey?.trim(); + if (!parsedJsonKey) continue; + const parseState = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); + if (parseState.status !== 'ready') continue; + return { parsedJsonKey }; + } + + return null; +} diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 341fd55..531dfd0 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -1,11 +1,14 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { documentKey, getParsedDocumentBlobByKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import { + normalizeDocumentParseStateForCurrentParserVersion, parseDocumentParseState, + resolveParsedPdfParserVersion, stringifyDocumentParseState, type DocumentParseState, } from '@/lib/server/documents/parse-state'; @@ -13,11 +16,13 @@ import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; import type { + ParsedPdfDocument, PdfLayoutJobBase, PdfLayoutJobResult, PdfLayoutProgress, WorkerOperationState, } from '@openreader/compute-core/api-contracts'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; type UserPdfLayoutJobRequest = PdfLayoutJobBase & { userId: string; @@ -41,7 +46,7 @@ function keyFor(input: UserPdfLayoutJobRequest): string { if (forceToken) { return `force:${input.documentId}:${input.namespace || ''}:${forceToken}`; } - return `shared:${input.documentId}:${input.namespace || ''}`; + return `shared:${input.documentId}`; } function sleep(ms: number): Promise { @@ -84,7 +89,7 @@ async function loadScopedRows(input: UserPdfLayoutJobRequest): Promise { + if (parsed) return parsed; + + try { + const json = await getParsedDocumentBlobByKey(parsedJsonKey); + return JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + return null; + } +} + async function updateParseStateForUsers(input: { documentId: string; userIds: string[]; @@ -125,6 +144,23 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise const deadline = Date.now() + FOLLOWER_WAIT_TIMEOUT_MS; while (Date.now() < deadline) { + const reusableParsed = await findReusableParsedPdfResult(input.documentId); + if (reusableParsed) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: reusableParsed.parsedJsonKey, + }); + return; + } + const rows = await loadScopedRows(input); const ready = rows.find(isReadyRow); if (ready) { @@ -132,6 +168,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; await updateParseStateForUsers({ documentId: input.documentId, @@ -142,7 +179,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise return; } - const statuses = rows.map((row) => parseDocumentParseState(row.parseState)); + const statuses = rows.map((row) => normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState))); const hasInFlight = statuses.some((state) => state.status === 'pending' || state.status === 'running'); const failedState = statuses.find((state) => state.status === 'failed'); if (failedState && !hasInFlight) { @@ -151,6 +188,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise progress: null, updatedAt: Date.now(), ...(failedState.error ? { error: failedState.error } : {}), + ...(failedState.parserVersion ? { parserVersion: failedState.parserVersion } : {}), }; await updateParseStateForUsers({ documentId: input.documentId, @@ -181,12 +219,30 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise // Non-force jobs can short-circuit by reusing existing ready output from // any row in the same document scope. if (!input.forceToken?.trim()) { + const reusableParsed = await findReusableParsedPdfResult(input.documentId); + if (reusableParsed) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: reusableParsed.parsedJsonKey, + }); + return; + } + const ready = scopedRows.find(isReadyRow); if (ready) { const readyState: DocumentParseState = { status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; await updateParseStateForUsers({ documentId: input.documentId, @@ -205,6 +261,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise status: 'running', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; const runningState = stringifyDocumentParseState(runningStateData); @@ -279,6 +336,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise status: mappedStatus, progress: snapshot.progress ?? null, updatedAt: now, + parserVersion: PDF_PARSER_VERSION, ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; @@ -305,6 +363,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise phase: progress.phase, }, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; @@ -327,12 +386,15 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); } + const parsedDoc = await loadParsedDocumentForResult(layout.parsed, parsedJsonKey); + const finalScopedRows = await loadScopedRows(input); const finalUserIds = userIdsFromRows(finalScopedRows); const readyState: DocumentParseState = { status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: resolveParsedPdfParserVersion(parsedDoc), }; const readyUserIds = finalUserIds.length > 0 ? finalUserIds : cohortUserIds; await updateParseStateForUsers({ @@ -385,6 +447,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise progress: null, updatedAt: Date.now(), error: message, + parserVersion: PDF_PARSER_VERSION, }; const failedUserIds = scopedUserIds.length > 0 ? scopedUserIds : [input.userId]; await updateParseStateForUsers({ diff --git a/tests/helpers.ts b/tests/helpers.ts index cfc9472..3a7835d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -188,10 +188,11 @@ async function dismissOnboardingModals(page: Page): Promise { * Wait for the play button to be clickable and click it */ export async function waitAndClickPlay(page: Page) { - // Wait for play button selector without disabled attribute - await expect(page.getByRole('button', { name: 'Play' })).toBeVisible(); + const playButton = page.getByRole('button', { name: 'Play' }); + await expect(playButton).toBeVisible(); + await expect(playButton).toBeEnabled({ timeout: 15000 }); // Play the TTS by clicking the button - await page.getByRole('button', { name: 'Play' }).click(); + await playButton.click(); // Use resilient processing transition helper (tolerates fast completion) await expectProcessingTransition(page); } diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index b642b20..ea193f8 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -115,10 +115,12 @@ test.describe('EPUB resize pauses TTS', () => { await page.waitForTimeout(750); // After resize, playback should have paused (Play button visible) - await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 15000 }); + const playButton = page.getByRole('button', { name: 'Play' }); + await expect(playButton).toBeVisible({ timeout: 15000 }); + await expect(playButton).toBeEnabled({ timeout: 15000 }); // Resume playback and ensure processing -> playing - await page.getByRole('button', { name: 'Play' }).click(); + await playButton.click(); await expectProcessingTransition(page); }); }); diff --git a/tests/unit/pdf-op-key.vitest.spec.ts b/tests/unit/pdf-op-key.vitest.spec.ts new file mode 100644 index 0000000..fbe746e --- /dev/null +++ b/tests/unit/pdf-op-key.vitest.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import { buildPdfOpKey } from '../../src/lib/server/compute/worker'; + +describe('pdf worker op key', () => { + test('includes parser version in the reuse boundary', () => { + expect(buildPdfOpKey({ + documentId: 'doc-1', + namespace: null, + documentObjectKey: 'documents/doc-1.pdf', + })).toBe(`pdf_layout|v1|${PDF_PARSER_VERSION}|doc-1||documents/doc-1.pdf|`); + }); +}); diff --git a/tests/unit/pdf-parse-state.vitest.spec.ts b/tests/unit/pdf-parse-state.vitest.spec.ts new file mode 100644 index 0000000..aa3384b --- /dev/null +++ b/tests/unit/pdf-parse-state.vitest.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import { + normalizeDocumentParseStateForCurrentParserVersion, + parseDocumentParseState, + stringifyDocumentParseState, +} from '../../src/lib/server/documents/parse-state'; + +describe('document parse state parser-version handling', () => { + test('preserves parserVersion through stringify/parse', () => { + const state = parseDocumentParseState(stringifyDocumentParseState({ + status: 'ready', + progress: null, + updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, + })); + + expect(state).toEqual({ + status: 'ready', + progress: null, + updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, + }); + }); + + test('invalidates ready and inflight states from older parser versions', () => { + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: 'old-parser', + opId: 'op-old', + }, 2222)).toEqual({ + status: 'pending', + progress: null, + updatedAt: 2222, + }); + + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'running', + progress: { + totalPages: 10, + pagesParsed: 3, + currentPage: 4, + phase: 'infer', + }, + updatedAt: 1111, + parserVersion: 'old-parser', + opId: 'op-old', + }, 3333)).toEqual({ + status: 'pending', + progress: null, + updatedAt: 3333, + }); + }); + + test('keeps failed states and current-version ready states intact', () => { + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'failed', + progress: null, + updatedAt: 1111, + error: 'no text layer', + parserVersion: 'old-parser', + })).toEqual({ + status: 'failed', + progress: null, + updatedAt: 1111, + error: 'no text layer', + parserVersion: 'old-parser', + }); + + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: PDF_PARSER_VERSION, + })).toEqual({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: PDF_PARSER_VERSION, + }); + }); +}); diff --git a/tests/unit/worker-parse-state.vitest.spec.ts b/tests/unit/worker-parse-state.vitest.spec.ts index dfdd37e..91563b3 100644 --- a/tests/unit/worker-parse-state.vitest.spec.ts +++ b/tests/unit/worker-parse-state.vitest.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; import { documentParseStateFromWorkerState, @@ -11,7 +12,7 @@ function makeWorkerState( ): WorkerOperationState { return { opId: 'op-123', - opKey: 'pdf_layout|v1|doc-1', + opKey: `pdf_layout|v1|${PDF_PARSER_VERSION}|doc-1`, kind: 'pdf_layout', jobId: 'job-123', status: 'queued', @@ -41,6 +42,7 @@ describe('worker parse state mapping', () => { status: 'pending', progress: null, updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', }); @@ -66,6 +68,7 @@ describe('worker parse state mapping', () => { phase: 'infer', }, updatedAt: 5678, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', }); @@ -84,6 +87,7 @@ describe('worker parse state mapping', () => { status: 'failed', progress: null, updatedAt: 9999, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', error: 'layout model crashed',