refactor: improve async state and session reliability

Implement AbortController in ConfigContext, PDFContext, and preference sync to prevent race conditions during session changes or rapid navigation. Refactor document preview routes into shared utilities and add LRU caching for text previews to optimize memory. Update documentation for Better Auth schema ownership and migration workflows. Fix issues with Postgres system user seeding and better-sqlite3 configuration.
This commit is contained in:
Richard R 2026-02-15 15:13:25 -07:00
parent 8e4f0b0ca9
commit 4d13b5b821
30 changed files with 328 additions and 218 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
node-linker=hoisted

View file

@ -12,12 +12,14 @@ This page covers database mode selection for OpenReader WebUI.
## What the database stores
- Document and audiobook metadata/state used by server routes.
- Auth/session tables when auth is enabled.
- Auth/session tables (`user`, `session`, `account`, `verification`) when auth is enabled — schema is auto-generated by Better Auth.
- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
- User settings preferences (`user_preferences`) when auth is enabled.
- User reading progress (`user_document_progress`) when auth is enabled.
- Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails.
App-specific tables are manually maintained in Drizzle schema files, while auth tables are generated by the Better Auth CLI. Both are migrated together via Drizzle. See [Migrations](./migrations) for details.
## Related variables
- `POSTGRES_URL`

View file

@ -76,20 +76,34 @@ pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
## Generate migrations
`pnpm generate` creates migration files for both configs in one run:
`pnpm generate` is a two-phase script for contributors and schema changes:
- `drizzle.config.sqlite.ts`
- `drizzle.config.pg.ts`
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
:::note
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
:::
### Schema ownership
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
- `src/db/schema_auth_sqlite.ts`
- `src/db/schema_auth_postgres.ts`
App-specific tables are manually maintained in the standard Drizzle schema files:
- `src/db/schema_sqlite.ts`
- `src/db/schema_postgres.ts`
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
<Tabs groupId="generate-migration-commands">
<TabItem value="project-script" label="Project Script" default>
```bash
# Generate migration files for both SQLite and Postgres outputs
# Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
pnpm generate
```
@ -97,13 +111,17 @@ pnpm generate
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
```bash
# Generate SQLite migrations
# Generate SQLite migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
# Generate Postgres migrations
# Generate Postgres migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
```
:::warning
Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first.
:::
</TabItem>
</Tabs>

View file

@ -15,7 +15,7 @@ npm install -g pnpm
```
- A reachable TTS API server
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required)
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required unless using external S3 storage)
<Tabs groupId="seaweedfs-install">
<TabItem value="macos" label="macOS" default>
@ -93,10 +93,10 @@ Optional:
For all environment variables, see [Environment Variables](../reference/environment-variables).
:::
For app/auth behavior, see [Auth](../configure/auth).
For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage).
For database mode, see [Database](../configure/database).
For migration behavior and commands, see [Migrations](../configure/migrations).
See [Auth](../configure/auth) for app/auth behavior.
Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage).
Refer to [Database](../configure/database) for database modes.
Learn about migration behavior and commands in [Migrations](../configure/migrations).
4. Run DB migrations.

View file

@ -44,7 +44,7 @@ docker run --name openreader-webui \
-e API_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \
-e BASE_URL=http://localhost:3003 \
-e AUTH_SECRET=<paste_the_output_of_openssl_here> \
-e AUTH_SECRET=$(openssl rand -base64 32) \
ghcr.io/richardr1126/openreader-webui:latest
```

View file

@ -51,8 +51,6 @@ This is the single reference page for OpenReader WebUI environment variables.
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
| `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path |
## Detailed Reference
## Client Runtime and Feature Flags
### NEXT_PUBLIC_NODE_ENV

View file

@ -31,6 +31,8 @@ title: Stack
- State sync: request-based today (not realtime push updates)
- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters
- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`)
- App tables are manually maintained in Drizzle schema files
- Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle
- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3
- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps

View file

@ -6,7 +6,7 @@ const nextConfig: NextConfig = {
canvas: '@napi-rs/canvas',
},
},
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static"],
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
outputFileTracingIncludes: {
'/api/audiobook': [
'./node_modules/ffmpeg-static/ffmpeg',

View file

@ -352,9 +352,7 @@ async function main() {
runtimeEnv.AWS_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID;
runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY;
if (!runtimeEnv.S3_ACCESS_KEY_ID || !runtimeEnv.S3_SECRET_ACCESS_KEY) {
throw new Error('Failed to initialize embedded S3 credentials.');
}
fs.mkdirSync(runtimeEnv.WEED_MINI_DIR, { recursive: true });

View file

@ -51,6 +51,10 @@ export default function SignUpPage() {
setError('Please enter a valid email address');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
const { strength } = validatePassword(password);
if (strength < 3) {
setError('Password is too weak');

View file

@ -8,8 +8,10 @@ export async function DELETE() {
return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 });
}
const reqHeaders = await headers();
const session = await auth.api.getSession({
headers: await headers()
headers: reqHeaders
});
if (!session?.user?.id) {
@ -19,7 +21,7 @@ export async function DELETE() {
try {
// Use Better Auth's built-in deleteUser to handle cascading cleanup
await auth.api.deleteUser({
headers: await headers(),
headers: reqHeaders,
body: {},
});

View file

@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
const exists = chapters.length > 0 || hasComplete || settings !== null;
if (!exists) {
await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
// Deleting the audiobook row cascades to audiobookChapters via bookFk
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
return NextResponse.json({
chapters: [],

View file

@ -8,57 +8,15 @@ import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobst
import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { isS3Configured } from '@/lib/server/s3';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
export async function GET(req: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
type: documents.type,
lastModified: documents.lastModified,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
type: string;
lastModified: number;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (!isPreviewableDocumentType(doc.type)) {
return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 });
}
const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const { doc, testNamespace, id } = validation;
const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`;
const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;

View file

@ -8,57 +8,15 @@ import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobst
import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { isS3Configured } from '@/lib/server/s3';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
export async function GET(req: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
type: documents.type,
lastModified: documents.lastModified,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
type: string;
lastModified: number;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (!isPreviewableDocumentType(doc.type)) {
return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 });
}
const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const { doc, testNamespace, id } = validation;
const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;
const preview = await ensureDocumentPreview(

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth';
import { isValidDocumentId } from '@/lib/server/documents-blobstore';
import { isPreviewableDocumentType } from '@/lib/server/document-previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { isS3Configured } from '@/lib/server/s3';
export function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
export type ValidatedPreviewRequest = {
doc: {
id: string;
userId: string;
type: string;
lastModified: number;
};
testNamespace: string | null;
id: string;
errorResponse?: undefined;
} | {
doc?: undefined;
testNamespace?: undefined;
id?: undefined;
errorResponse: NextResponse | Response;
};
export async function validatePreviewRequest(req: NextRequest): Promise<ValidatedPreviewRequest> {
if (!isS3Configured()) return { errorResponse: s3NotConfiguredResponse() };
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes };
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
// Deduplicate allowedUserIds
const allowedUserIds = Array.from(new Set(
ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]
));
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return { errorResponse: NextResponse.json({ error: 'Invalid id' }, { status: 400 }) };
}
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
type: documents.type,
lastModified: documents.lastModified,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
type: string;
lastModified: number;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return { errorResponse: NextResponse.json({ error: 'Not found' }, { status: 404 }) };
}
if (!isPreviewableDocumentType(doc.type)) {
return { errorResponse: NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }) };
}
return {
doc,
testNamespace,
id
};
}

View file

@ -165,9 +165,9 @@ export async function GET(req: NextRequest) {
const idsParam = url.searchParams.get('ids');
const targetIds = idsParam
? idsParam
.split(',')
.map((id) => id.trim().toLowerCase())
.filter((id) => isValidDocumentId(id))
.split(',')
.map((id) => id.trim().toLowerCase())
.filter((id) => isValidDocumentId(id))
: null;
if (idsParam && (!targetIds || targetIds.length === 0)) {
@ -282,7 +282,7 @@ export async function DELETE(req: NextRequest) {
await deleteDocumentBlob(id, testNamespace);
} catch (error) {
if (!isMissingBlobError(error)) {
throw error;
console.error(`[best-effort] Failed to delete blob for document ${id}, orphaned blob may need manual cleanup:`, error);
}
}

View file

@ -58,7 +58,7 @@ export async function GET(req: NextRequest) {
const isAnonymous = Boolean(session.user.isAnonymous);
const ip = getClientIp(req);
const device = isAnonymous && ttsRateLimitEnabled ? getOrCreateDeviceId(req) : null;
const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const result = await rateLimiter.getCurrentUsage(
{

View file

@ -13,6 +13,12 @@ import { isAuthEnabled } from '@/lib/server/auth-config';
import { getClientIp } from '@/lib/server/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id';
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
if (didCreate && deviceId) {
setDeviceIdCookie(response, deviceId);
}
}
type CustomVoice = string;
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
voice: SpeechCreateParams['voice'] | CustomVoice;
@ -210,9 +216,7 @@ export async function POST(req: NextRequest) {
},
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
@ -286,9 +290,7 @@ export async function POST(req: NextRequest) {
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
@ -304,9 +306,7 @@ export async function POST(req: NextRequest) {
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
@ -338,9 +338,7 @@ export async function POST(req: NextRequest) {
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} finally {
@ -392,9 +390,7 @@ export async function POST(req: NextRequest) {
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} catch (error) {

View file

@ -25,22 +25,13 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
async function getClaimableCounts(
unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> {
const [docCount] = await db
.select({ count: count() })
.from(documents)
.where(eq(documents.userId, unclaimedUserId));
const [bookCount] = await db
.select({ count: count() })
.from(audiobooks)
.where(eq(audiobooks.userId, unclaimedUserId));
const [preferencesCount] = await db
.select({ count: count() })
.from(userPreferences)
.where(eq(userPreferences.userId, unclaimedUserId));
const [progressCount] = await db
.select({ count: count() })
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, unclaimedUserId));
const [[docCount], [bookCount], [preferencesCount], [progressCount]] =
await Promise.all([
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
]);
return {
documents: Number(docCount?.count ?? 0),

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { userDocumentProgress } from '@/db/schema';
import type { ReaderType } from '@/types/user-state';
@ -84,12 +84,12 @@ export async function PUT(req: NextRequest) {
const body = (await req.json().catch(() => null)) as
| {
documentId?: unknown;
readerType?: unknown;
location?: unknown;
progress?: unknown;
clientUpdatedAtMs?: unknown;
}
documentId?: unknown;
readerType?: unknown;
location?: unknown;
progress?: unknown;
clientUpdatedAtMs?: unknown;
}
| null;
const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : '';
@ -111,8 +111,8 @@ export async function PUT(req: NextRequest) {
body?.progress == null
? null
: Number.isFinite(body.progress)
? Math.max(0, Math.min(1, Number(body.progress)))
: null;
? Math.max(0, Math.min(1, Number(body.progress)))
: null;
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
const existingRows = await db
@ -167,6 +167,7 @@ export async function PUT(req: NextRequest) {
clientUpdatedAtMs,
updatedAt,
},
setWhere: sql`${userDocumentProgress.clientUpdatedAtMs} <= ${clientUpdatedAtMs}`,
});
return NextResponse.json({

View file

@ -131,6 +131,7 @@ h1, h2, h3, h4, h5, h6 {
.pdf-page-stage {
/* Ensure we always paint something (matches app theme) while react-pdf swaps canvases */
background: var(--background);
position: relative;
}
.pdf-viewer .react-pdf__Page {
@ -139,16 +140,13 @@ h1, h2, h3, h4, h5, h6 {
isolation: isolate;
}
/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */
.pdf-viewer .react-pdf__Page canvas {
display: block;
background: var(--background);
/* GPU hint; helps reduce flicker on some browsers */
transform: translateZ(0);
backface-visibility: hidden;
}
/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */
.pdf-viewer .react-pdf__Page canvas {
opacity: 0;
animation: pdf-canvas-fade-in 140ms ease-out forwards;
}
@ -163,9 +161,7 @@ h1, h2, h3, h4, h5, h6 {
display: none !important;
}
.pdf-page-stage {
position: relative;
}
/* App shell utility (fullscreen, vertical layout) */
.app-shell {

View file

@ -24,7 +24,7 @@ interface ProvidersProps {
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions }: ProvidersProps) {
const pathname = usePathname();
const isAuthPage = pathname === '/signin' || pathname === '/signup';
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
if (isAuthPage) {
return (

View file

@ -296,10 +296,10 @@ export function SettingsModal({ className = '' }: { className?: string }) {
try {
if (deleteDocsMode === 'user') {
await deleteDocuments();
await refreshDocuments().catch(() => {});
await refreshDocuments().catch(() => { });
} else if (deleteDocsMode === 'unclaimed') {
await deleteDocuments({ scope: 'unclaimed' });
await refreshDocuments().catch(() => {});
await refreshDocuments().catch(() => { });
}
} catch (error) {
console.error('Delete failed:', error);
@ -366,8 +366,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const userDeleteTitle = authEnabled ? (isAnonymous ? 'Delete Anonymous Docs' : 'Delete All User Docs') : 'Delete Server Docs';
const userDeleteMessage = authEnabled
? (isAnonymous
? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.'
: 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.')
? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.'
: 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.')
: 'Are you sure you want to delete all documents from the server? This action cannot be undone.';
const [unclaimedCounts, setUnclaimedCounts] = useState<{ documents: number; audiobooks: number } | null>(null);
@ -386,7 +386,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks });
}
})
.catch(() => {});
.catch(() => { });
}, [authEnabled, session?.user]);
return (
@ -661,7 +661,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setModelValue('kokoro');
setCustomModelInput('');
setLocalTTSInstructions('');
setLocalTTSInstructions('');
}}
>
Reset
@ -876,8 +875,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<p className="text-sm text-muted mb-3">
{session?.user?.isAnonymous
? (allowAnonymousAuthSessions
? 'You are using an anonymous session. Sign up to save your progress permanently.'
: 'Anonymous sessions are disabled. Please sign in or create an account.')
? 'You are using an anonymous session. Sign up to save your progress permanently.'
: 'Anonymous sessions are disabled. Please sign in or create an account.')
: 'No active session. Please sign in or create an account.'}
</p>
<div className="grid grid-cols-2 gap-3">

View file

@ -19,8 +19,34 @@ interface DocumentPreviewProps {
doc: DocumentListDocument;
}
const MAX_TEXT_PREVIEW_CACHE = 100;
const textPreviewCache = new Map<string, string>();
/** Read from cache and promote entry to most-recently-used. */
function textPreviewCacheGet(key: string): string | undefined {
const value = textPreviewCache.get(key);
if (value !== undefined) {
// Re-insert to move to end (most-recently-used)
textPreviewCache.delete(key);
textPreviewCache.set(key, value);
}
return value;
}
/** Write to cache, evicting the least-recently-used entry when over the cap. */
function textPreviewCacheSet(key: string, value: string): void {
// If the key already exists, delete first so re-insertion moves it to the end
if (textPreviewCache.has(key)) {
textPreviewCache.delete(key);
}
textPreviewCache.set(key, value);
if (textPreviewCache.size > MAX_TEXT_PREVIEW_CACHE) {
// Map keys iterate in insertion order; first key is the LRU entry
const oldest = textPreviewCache.keys().next().value;
if (oldest !== undefined) textPreviewCache.delete(oldest);
}
}
export function DocumentPreview({ doc }: DocumentPreviewProps) {
const isPDF = doc.type === 'pdf';
const isEPUB = doc.type === 'epub';
@ -71,7 +97,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
return;
}
const cachedText = textPreviewCache.get(previewKey);
const cachedText = textPreviewCacheGet(previewKey);
if (cachedText) {
setTextPreview(cachedText);
setImagePreview(null);
@ -154,7 +180,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
signal: controller.signal,
});
if (cancelled) return;
textPreviewCache.set(previewKey, snippet);
textPreviewCacheSet(previewKey, snippet);
setTextPreview(snippet);
setImagePreview(null);
return;
@ -238,7 +264,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
doc.id,
Number(doc.lastModified),
previewKey,
).catch(() => {});
).catch(() => { });
}}
/>
{isImageReady ? <div className="absolute inset-0 bg-gradient-to-t from-black/35 via-black/0 to-black/15" /> : null}

View file

@ -86,7 +86,7 @@ export function DexieMigrationModal() {
return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener);
}, [checkAndMaybePrompt]);
const title = useMemo(() => `Upload your local documents?`, []);
const title = 'Upload your local documents?';
const handleSkip = useCallback(async () => {
await updateAppConfig({ documentsMigrationPrompted: true });
@ -124,7 +124,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {});
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else if (doc.type === 'epub') {
const full = epubById.get(doc.id) ?? null;
@ -137,7 +137,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {});
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else {
const full = htmlById.get(doc.id) ?? null;
@ -150,7 +150,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {});
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
}
}

View file

@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
@ -64,7 +64,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (!authEnabled) return;
if (!authEnabled || sessionKey === 'no-session') return;
const syncedPatch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
@ -74,8 +74,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
}
if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch);
}, [authEnabled]);
scheduleUserPreferencesSync(syncedPatch, sessionKey);
}, [authEnabled, sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => {
return () => {
cancelPendingPreferenceSync();
};
}, [sessionKey]);
const buildSyncedPreferencePatch = useCallback((
source: Partial<AppConfigValues>,
@ -152,35 +159,44 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
void run();
}, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async () => {
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady || !authEnabled) return;
try {
const remote = await getUserPreferences();
const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error);
}
}, [isDBReady, authEnabled]);
useEffect(() => {
if (!isDBReady || !authEnabled || isSessionPending) return;
refreshSyncedPreferencesFromServer().catch((error) => {
const controller = new AbortController();
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Synced preferences refresh failed:', error);
});
return () => controller.abort();
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => {
if (!isDBReady || !authEnabled) return;
let activeController: AbortController | null = null;
const onFocus = () => {
refreshSyncedPreferencesFromServer().catch((error) => {
if (activeController) activeController.abort();
activeController = new AbortController();
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Focus synced preferences refresh failed:', error);
});
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort();
};
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
@ -206,9 +222,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
const controller = new AbortController();
const run = async () => {
try {
const remote = await getUserPreferences();
const remote = await getUserPreferences({ signal: controller.signal });
if (remote?.hasStoredPreferences) return;
// Seed only user-customized (non-default) values. This prevents fresh/default
@ -216,15 +234,19 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
if (Object.keys(patch).length === 0) return;
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() });
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
}
};
run().catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Initial synced preferences seed failed:', error);
});
return () => controller.abort();
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape

View file

@ -1,7 +1,7 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
import type { BaseDocument, DocumentType } from '@/types/documents';
import type { BaseDocument } from '@/types/documents';
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents';
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache';
import { useAuthSession } from '@/hooks/useAuthSession';
@ -27,9 +27,7 @@ interface DocumentContextType {
refreshDocuments: () => Promise<void>;
clearPDFs: () => Promise<void>;
clearEPUBs: () => Promise<void>;
clearHTML: () => Promise<void>;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
@ -118,19 +116,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const clearByType = useCallback(async (type: DocumentType) => {
const toDelete = (docs ?? []).filter((d) => d.type === type).map((d) => d.id);
if (toDelete.length) {
await deleteDocuments({ ids: toDelete });
}
// Evict cache and update local list
await Promise.allSettled(toDelete.map((id) => Promise.all([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)])));
setDocs((prev) => (prev ?? []).filter((d) => d.type !== type));
}, [docs]);
const clearPDFs = useCallback(async () => clearByType('pdf'), [clearByType]);
const clearEPUBs = useCallback(async () => clearByType('epub'), [clearByType]);
const clearHTML = useCallback(async () => clearByType('html'), [clearByType]);
// Removed unused clear functions
return (
<DocumentContext.Provider value={{
@ -147,9 +133,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
removeHTMLDocument,
isHTMLLoading: isLoading,
refreshDocuments,
clearPDFs,
clearEPUBs,
clearHTML,
}}>
{children}
</DocumentContext.Provider>

View file

@ -156,6 +156,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const loadSeqRef = useRef(0);
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
const docLoadSeqRef = useRef(0);
const docLoadAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
pdfDocumentRef.current = pdfDocument;
}, [pdfDocument]);
@ -317,6 +321,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* @returns {Promise<void>}
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
// --- race-condition guard ---
const seq = ++docLoadSeqRef.current;
docLoadAbortRef.current?.abort();
const controller = new AbortController();
docLoadAbortRef.current = controller;
try {
// Reset any state tied to the previously loaded PDF. This prevents calling
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
@ -335,13 +345,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
setCurrDocName(undefined);
setCurrDocData(undefined);
const meta = await getDocumentMetadata(id);
const meta = await getDocumentMetadata(id, { signal: controller.signal });
if (seq !== docLoadSeqRef.current) return; // stale
if (!meta) {
console.error('Document not found on server');
return;
}
const doc = await ensureCachedDocument(meta);
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
if (seq !== docLoadSeqRef.current) return; // stale
if (doc.type !== 'pdf') {
console.error('Document is not a PDF');
return;
@ -352,7 +364,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// buffer passed into the worker; we always pass clones to react-pdf.
setCurrDocData(doc.data.slice(0));
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.error('Failed to get document:', error);
} finally {
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
if (docLoadAbortRef.current === controller) {
docLoadAbortRef.current = null;
}
}
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
@ -364,6 +382,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = undefined;
loadSeqRef.current += 1;
// Invalidate any in-flight setCurrentDocument load.
docLoadSeqRef.current += 1;
docLoadAbortRef.current?.abort();
docLoadAbortRef.current = null;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}

View file

@ -33,18 +33,24 @@ export function ensureSystemUserExists(userId: string) {
const drizzleDb = getDrizzleDB();
try {
if (dbIsPostgres) {
// Fire-and-forget: Postgres drizzle returns a Promise. We intentionally
// don't await (to keep this helper synchronous for SQLite compat), but we
// only mark the user as seeded once the insert actually resolves.
drizzleDb.execute(sql`
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false)
ON CONFLICT (id) DO NOTHING
`).catch(() => { /* table may not exist yet */ });
`).then(() => {
seededUserIds.add(userId);
}).catch(() => { /* table may not exist yet */ });
} else {
// better-sqlite3 driver is fully synchronous — no Promise returned.
drizzleDb.run(sql`
INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0)
`);
seededUserIds.add(userId);
}
seededUserIds.add(userId);
} catch {
// Silently ignore the user table may not exist yet on first boot before migrations run.
}

View file

@ -56,30 +56,69 @@ export async function putUserPreferences(
type PendingPreferenceSync = {
patch: SyncedPreferencesPatch;
timer: ReturnType<typeof setTimeout> | null;
sessionId: string | null;
};
const pendingPreferenceSync: PendingPreferenceSync = {
patch: {},
timer: null,
sessionId: null,
};
export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void {
let activeSyncController: AbortController | null = null;
/**
* Cancel any pending debounced preference sync and abort in-flight requests.
* Call this on session change / sign-out to prevent cross-account writes.
*/
export function cancelPendingPreferenceSync(): void {
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
pendingPreferenceSync.timer = null;
}
pendingPreferenceSync.patch = {};
pendingPreferenceSync.sessionId = null;
if (activeSyncController) {
activeSyncController.abort();
activeSyncController = null;
}
}
export function scheduleUserPreferencesSync(
patch: SyncedPreferencesPatch,
sessionId: string,
debounceMs: number = 600,
): void {
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
pendingPreferenceSync.sessionId = sessionId;
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
}
const capturedSessionId = sessionId;
pendingPreferenceSync.timer = setTimeout(async () => {
// If the session changed between scheduling and firing, discard.
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
pendingPreferenceSync.timer = null;
if (Object.keys(payload).length === 0) return;
// Abort any previous in-flight sync and create a fresh controller.
if (activeSyncController) activeSyncController.abort();
activeSyncController = new AbortController();
try {
await putUserPreferences(payload);
await putUserPreferences(payload, { signal: activeSyncController.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to sync user preferences:', error);
} finally {
activeSyncController = null;
}
}, debounceMs);
}