refactor(settings): remove enableDestructiveDeleteActions runtime flag and related UI

Eliminate the enableDestructiveDeleteActions feature flag from runtime
configuration, admin panel, environment docs, and user settings modal.
Remove all references, toggles, and documentation for this flag. This
simplifies the runtime config and user interface by consolidating
destructive actions under account deletion only.

BREAKING CHANGE: The enableDestructiveDeleteActions runtime flag is no longer supported. Any configuration or code depending on this flag must be updated.
This commit is contained in:
Richard R 2026-06-01 20:38:54 -06:00
parent e295ee4103
commit 28f8d50c05
15 changed files with 151 additions and 91 deletions

View file

@ -87,4 +87,4 @@ S3_BUCKET=
# (Optional) v4 JSON seed for first-boot runtime config + shared providers. # (Optional) v4 JSON seed for first-boot runtime config + shared providers.
# If both are set, RUNTIME_SEED_JSON_PATH is used. # If both are set, RUNTIME_SEED_JSON_PATH is used.
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json # RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"enableDestructiveDeleteActions":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} # RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]}

View file

@ -76,7 +76,6 @@ Runtime-editable settings, one row per key:
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
| `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). |
Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag. Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag.

View file

@ -78,7 +78,6 @@ After the first successful deploy and admin login, open **Settings → Admin** a
- **Shared providers**: create/edit your provider key(s) here (encrypted at rest). - **Shared providers**: create/edit your provider key(s) here (encrypted at rest).
- **Site features**: - **Site features**:
- `enableDocxConversion=false` on Vercel (`soffice` unavailable). - `enableDocxConversion=false` on Vercel (`soffice` unavailable).
- `enableDestructiveDeleteActions=false` for safer public deployments.
- `enableTtsProvidersTab=false` if you want shared-provider-only UX. - `enableTtsProvidersTab=false` if you want shared-provider-only UX.
- `enableUserSignups=true` unless you explicitly want an invite-only deployment. - `enableUserSignups=true` unless you explicitly want an invite-only deployment.
- `restrictUserApiKeys=true` to block user BYOK through the hosted server. - `restrictUserApiKeys=true` to block user BYOK through the hosted server.

View file

@ -414,7 +414,6 @@ Example:
"enableTtsProvidersTab": true, "enableTtsProvidersTab": true,
"enableAudiobookExport": true, "enableAudiobookExport": true,
"enableDocxConversion": true, "enableDocxConversion": true,
"enableDestructiveDeleteActions": true,
"showAllProviderModels": true, "showAllProviderModels": true,
"disableTtsRateLimit": true, "disableTtsRateLimit": true,
"ttsDailyLimitAnonymous": 50000, "ttsDailyLimitAnonymous": 50000,

View file

@ -5,7 +5,10 @@ import { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { createOrReusePdfWorkerOperation } from '@/lib/server/compute/worker-op-create'; import { createOrReusePdfWorkerOperation } from '@/lib/server/compute/worker-op-create';
import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state'; import {
documentParseStateFromWorkerState,
snapshotFromWorkerState,
} from '@/lib/server/compute/worker-parse-state';
import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state';
import { import {
documentKey, documentKey,
@ -101,8 +104,14 @@ async function finalizeFromWorkerState(input: {
logger: ServerLogger; logger: ServerLogger;
}): Promise<NextResponse> { }): Promise<NextResponse> {
const snapshot = snapshotFromWorkerState(input.workerState); const snapshot = snapshotFromWorkerState(input.workerState);
const workerParseState = documentParseStateFromWorkerState(input.workerState);
if (snapshot.parseStatus === 'pending' || snapshot.parseStatus === 'running') { if (snapshot.parseStatus === 'pending' || snapshot.parseStatus === 'running') {
await writeParseRowState({
documentId: input.row.id,
userId: input.row.userId,
parseState: stringifyDocumentParseState(workerParseState),
});
return NextResponse.json({ return NextResponse.json({
parseStatus: snapshot.parseStatus, parseStatus: snapshot.parseStatus,
parseProgress: snapshot.parseProgress, parseProgress: snapshot.parseProgress,
@ -114,12 +123,7 @@ async function finalizeFromWorkerState(input: {
await writeParseRowState({ await writeParseRowState({
documentId: input.row.id, documentId: input.row.id,
userId: input.row.userId, userId: input.row.userId,
parseState: stringifyDocumentParseState({ parseState: stringifyDocumentParseState(workerParseState),
status: 'failed',
progress: null,
updatedAt: Date.now(),
error: input.workerState.error?.message ?? 'Worker parse failed',
}),
}); });
return NextResponse.json({ return NextResponse.json({
@ -152,11 +156,7 @@ async function finalizeFromWorkerState(input: {
await writeParseRowState({ await writeParseRowState({
documentId: input.row.id, documentId: input.row.id,
userId: input.row.userId, userId: input.row.userId,
parseState: stringifyDocumentParseState({ parseState: stringifyDocumentParseState(workerParseState),
status: 'ready',
progress: null,
updatedAt: Date.now(),
}),
parsedJsonKey, parsedJsonKey,
}); });
@ -278,6 +278,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
}); });
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig); await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created); const snapshot = snapshotFromWorkerState(created);
await writeParseRowState({
documentId: row.id,
userId: row.userId,
parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)),
});
return NextResponse.json({ return NextResponse.json({
parseStatus: snapshot.parseStatus, parseStatus: snapshot.parseStatus,
parseProgress: snapshot.parseProgress, parseProgress: snapshot.parseProgress,
@ -409,6 +414,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig); await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created); const snapshot = snapshotFromWorkerState(created);
await writeParseRowState({
documentId: row.id,
userId: row.userId,
parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)),
});
return NextResponse.json({ return NextResponse.json({
parseStatus: snapshot.parseStatus, parseStatus: snapshot.parseStatus,

View file

@ -22,7 +22,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
@ -222,7 +222,6 @@ export function SettingsModal({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllProviderModels = runtimeConfig.showAllProviderModels; const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys; const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
@ -258,7 +257,6 @@ export function SettingsModal({
const [showProgress, setShowProgress] = useState(false); const [showProgress, setShowProgress] = useState(false);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [abortController, setAbortController] = useState<AbortController | null>(null); const [abortController, setAbortController] = useState<AbortController | null>(null);
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { baseUrl: authBaseUrl } = useAuthConfig(); const { baseUrl: authBaseUrl } = useAuthConfig();
@ -441,17 +439,6 @@ export function SettingsModal({
} }
}; };
const handleDeleteDocs = async () => {
try {
await deleteDocuments();
await refreshDocuments().catch(() => { });
} catch (error) {
console.error('Delete failed:', error);
} finally {
setShowDeleteDocsConfirm(false);
}
};
const handleSignOut = async () => { const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl); const client = getAuthClient(authBaseUrl);
await client.signOut(); await client.signOut();
@ -1189,21 +1176,19 @@ export function SettingsModal({
Disconnect account Disconnect account
</Button> </Button>
{enableDestructiveDelete && ( <div className="pt-4 mt-4 border-t border-line-soft">
<div className="pt-4 mt-4 border-t border-line-soft"> <label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label> <Button
<Button onClick={() => setShowDeleteAccountConfirm(true)}
onClick={() => setShowDeleteAccountConfirm(true)} variant="danger"
variant="danger" size="md"
size="md" >
> Delete Account
Delete Account </Button>
</Button> <p className="text-xs text-soft mt-2">
<p className="text-xs text-soft mt-2"> Permanently deletes your account and all data.
Permanently deletes your account and all data. </p>
</p> </div>
</div>
)}
</> </>
) : ( ) : (
<div className="pt-2 border-t border-line-soft"> <div className="pt-2 border-t border-line-soft">
@ -1246,16 +1231,6 @@ export function SettingsModal({
)} )}
</ModalFrame> </ModalFrame>
<ConfirmDialog
isOpen={showDeleteDocsConfirm}
onClose={() => setShowDeleteDocsConfirm(false)}
onConfirm={handleDeleteDocs}
title="Delete All Data"
message="Are you sure you want to delete all documents from the server? This action cannot be undone."
confirmText="Delete"
isDangerous={true}
/>
<ConfirmDialog <ConfirmDialog
isOpen={showDeleteAccountConfirm} isOpen={showDeleteAccountConfirm}
onClose={() => setShowDeleteAccountConfirm(false)} onClose={() => setShowDeleteAccountConfirm(false)}

View file

@ -495,14 +495,6 @@ export function AdminFeaturesPanel() {
right={renderSource('enableDocxConversion')} right={renderSource('enableDocxConversion')}
variant="flat" variant="flat"
/> />
<ToggleRow
label="Destructive delete buttons"
description='Show "Delete all data" actions (auth-off mode).'
checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
variant="flat"
/>
</Section> </Section>
<Section <Section

View file

@ -20,7 +20,6 @@ export interface RuntimeConfig {
enableTtsProvidersTab: boolean; enableTtsProvidersTab: boolean;
enableAudiobookExport: boolean; enableAudiobookExport: boolean;
enableDocxConversion: boolean; enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllProviderModels: boolean; showAllProviderModels: boolean;
disableTtsRateLimit: boolean; disableTtsRateLimit: boolean;
ttsDailyLimitAnonymous: number; ttsDailyLimitAnonymous: number;
@ -43,7 +42,6 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
enableTtsProvidersTab: true, enableTtsProvidersTab: true,
enableAudiobookExport: true, enableAudiobookExport: true,
enableDocxConversion: true, enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllProviderModels: true, showAllProviderModels: true,
disableTtsRateLimit: true, disableTtsRateLimit: true,
ttsDailyLimitAnonymous: 50_000, ttsDailyLimitAnonymous: 50_000,

View file

@ -71,7 +71,6 @@ export const RUNTIME_CONFIG_SCHEMA = {
enableTtsProvidersTab: booleanFlag(true), enableTtsProvidersTab: booleanFlag(true),
enableAudiobookExport: booleanFlag(true), enableAudiobookExport: booleanFlag(true),
enableDocxConversion: booleanFlag(true), enableDocxConversion: booleanFlag(true),
enableDestructiveDeleteActions: booleanFlag(true),
showAllProviderModels: runtimeBoolean(true), showAllProviderModels: runtimeBoolean(true),
disableTtsRateLimit: booleanFlag(true), disableTtsRateLimit: booleanFlag(true),
ttsDailyLimitAnonymous: positiveIntValue(50_000), ttsDailyLimitAnonymous: positiveIntValue(50_000),

View file

@ -1,5 +1,6 @@
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import type { DocumentParseState } from '@/lib/server/documents/parse-state';
export function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { export function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus {
switch (status) { switch (status) {
@ -26,6 +27,23 @@ export function snapshotFromWorkerState(
}; };
} }
export function documentParseStateFromWorkerState(
state: WorkerOperationState<PdfLayoutJobResult>,
nowMs = Date.now(),
): DocumentParseState {
const { parseStatus, parseProgress } = snapshotFromWorkerState(state);
return {
status: parseStatus,
progress: parseStatus === 'pending' || parseStatus === 'running'
? parseProgress
: null,
updatedAt: nowMs,
...(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 } : {}),
};
}
export function mergeNonReadyParseSnapshot(input: { export function mergeNonReadyParseSnapshot(input: {
parseStatus: PdfParseStatus; parseStatus: PdfParseStatus;
parseProgress: PdfParseProgress | null; parseProgress: PdfParseProgress | null;

View file

@ -22,6 +22,8 @@ export async function healStaleDocumentParseState(input: {
progress: null, progress: null,
updatedAt: Date.now(), updatedAt: Date.now(),
error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`, 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 } : {}),
})); }));
await db await db

View file

@ -255,9 +255,8 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
} }
}); });
// If auth is enabled, establish an anonymous session BEFORE navigation. // If we explicitly choose to bootstrap anonymous sessions for a test run,
// This keeps each test self-contained (no shared storageState) while ensuring // do it before navigation so protected startup routes do not intermittently 401.
// server routes that require auth don't intermittently 401 during app startup.
// await ensureAnonymousSession(page); // await ensureAnonymousSession(page);
// Navigate to the protected app home before each test // Navigate to the protected app home before each test
@ -412,22 +411,6 @@ export async function openSettingsDocumentsTab(page: Page) {
await settingsDialog.getByRole('button', { name: /^Documents$/ }).click(); await settingsDialog.getByRole('button', { name: /^Documents$/ }).click();
} }
// Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(page);
await page.getByRole('button', { name: /Delete all data/i }).click();
const heading = page.getByRole('heading', { name: /Delete All Data/i });
await expect(heading).toBeVisible({ timeout: 10000 });
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
await confirmBtn.click();
// Close any remaining modal layers
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
}
// Open the Voices dropdown from the TTS bar and return the button locator // Open the Voices dropdown from the TTS bar and return the button locator
export async function openVoicesMenu(page: Page) { export async function openVoicesMenu(page: Page) {
const ttsbar = page.locator('[data-app-ttsbar]'); const ttsbar = page.locator('[data-app-ttsbar]');

View file

@ -2,8 +2,6 @@ import { expect, test } from '@playwright/test';
import { setupTest, uploadAndDisplay } from './helpers'; import { setupTest, uploadAndDisplay } from './helpers';
const AUTH_ENABLED = Boolean(process.env.AUTH_SECRET && process.env.BASE_URL);
test.describe('Landing and app routing', () => { test.describe('Landing and app routing', () => {
test('public landing renders without anonymous auth bootstrap call', async ({ page }) => { test('public landing renders without anonymous auth bootstrap call', async ({ page }) => {
let anonymousSignInCalls = 0; let anonymousSignInCalls = 0;
@ -26,8 +24,6 @@ test.describe('Landing and app routing', () => {
}); });
test('existing authenticated session visiting / redirects to /app', async ({ page }) => { test('existing authenticated session visiting / redirects to /app', async ({ page }) => {
test.skip(!AUTH_ENABLED);
await page.goto('/app'); await page.goto('/app');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await page.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15000 }).catch(() => {}); await page.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15000 }).catch(() => {});
@ -46,7 +42,7 @@ test.describe('Landing and app routing', () => {
}); });
test('protected app routes redirect to /signin when anonymous auth is disabled', async ({ page }) => { test('protected app routes redirect to /signin when anonymous auth is disabled', async ({ page }) => {
test.skip(!AUTH_ENABLED || process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false'); test.skip(process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false');
await page.goto('/app'); await page.goto('/app');
await expect(page).toHaveURL(/\/signin$/); await expect(page).toHaveURL(/\/signin$/);

View file

@ -234,7 +234,6 @@ describe('runtime config JSON seeding', () => {
enableTtsProvidersTab: false, enableTtsProvidersTab: false,
enableAudiobookExport: false, enableAudiobookExport: false,
enableDocxConversion: false, enableDocxConversion: false,
enableDestructiveDeleteActions: false,
showAllProviderModels: false, showAllProviderModels: false,
disableTtsRateLimit: false, disableTtsRateLimit: false,
ttsDailyLimitAnonymous: 12345, ttsDailyLimitAnonymous: 12345,

View file

@ -0,0 +1,91 @@
import { describe, expect, test } from 'vitest';
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
import {
documentParseStateFromWorkerState,
snapshotFromWorkerState,
} from '../../src/lib/server/compute/worker-parse-state';
function makeWorkerState(
overrides: Partial<WorkerOperationState<PdfLayoutJobResult>>,
): WorkerOperationState<PdfLayoutJobResult> {
return {
opId: 'op-123',
opKey: 'pdf_layout|v1|doc-1',
kind: 'pdf_layout',
jobId: 'job-123',
status: 'queued',
queuedAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
...overrides,
};
}
describe('worker parse state mapping', () => {
test('maps queued worker state to pending parse state with op identifiers', () => {
const workerState = makeWorkerState({
status: 'queued',
progress: {
totalPages: 500,
pagesParsed: 0,
currentPage: 1,
phase: 'infer',
},
});
expect(snapshotFromWorkerState(workerState)).toEqual({
parseStatus: 'pending',
parseProgress: null,
});
expect(documentParseStateFromWorkerState(workerState, 1234)).toEqual({
status: 'pending',
progress: null,
updatedAt: 1234,
opId: 'op-123',
jobId: 'job-123',
});
});
test('maps running worker state to running parse state with progress', () => {
const workerState = makeWorkerState({
status: 'running',
progress: {
totalPages: 500,
pagesParsed: 120,
currentPage: 121,
phase: 'infer',
},
});
expect(documentParseStateFromWorkerState(workerState, 5678)).toEqual({
status: 'running',
progress: {
totalPages: 500,
pagesParsed: 120,
currentPage: 121,
phase: 'infer',
},
updatedAt: 5678,
opId: 'op-123',
jobId: 'job-123',
});
});
test('maps failed worker state to failed parse state and preserves the worker error', () => {
const workerState = makeWorkerState({
status: 'failed',
error: {
code: 'PDF_PARSE_FAILED',
message: 'layout model crashed',
},
});
expect(documentParseStateFromWorkerState(workerState, 9999)).toEqual({
status: 'failed',
progress: null,
updatedAt: 9999,
opId: 'op-123',
jobId: 'job-123',
error: 'layout model crashed',
});
});
});