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 ## What the database stores
- Document and audiobook metadata/state used by server routes. - 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). - TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
- User settings preferences (`user_preferences`) when auth is enabled. - User settings preferences (`user_preferences`) when auth is enabled.
- User reading progress (`user_document_progress`) 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. - 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 ## Related variables
- `POSTGRES_URL` - `POSTGRES_URL`

View file

@ -76,20 +76,34 @@ pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
## Generate migrations ## 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` 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`).
- `drizzle.config.pg.ts` 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 :::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. 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"> <Tabs groupId="generate-migration-commands">
<TabItem value="project-script" label="Project Script" default> <TabItem value="project-script" label="Project Script" default>
```bash ```bash
# Generate migration files for both SQLite and Postgres outputs # Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
pnpm generate pnpm generate
``` ```
@ -97,13 +111,17 @@ pnpm generate
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd"> <TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
```bash ```bash
# Generate SQLite migrations # Generate SQLite migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts 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 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> </TabItem>
</Tabs> </Tabs>

View file

@ -15,7 +15,7 @@ npm install -g pnpm
``` ```
- A reachable TTS API server - 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"> <Tabs groupId="seaweedfs-install">
<TabItem value="macos" label="macOS" default> <TabItem value="macos" label="macOS" default>
@ -93,10 +93,10 @@ Optional:
For all environment variables, see [Environment Variables](../reference/environment-variables). For all environment variables, see [Environment Variables](../reference/environment-variables).
::: :::
For app/auth behavior, see [Auth](../configure/auth). See [Auth](../configure/auth) for app/auth behavior.
For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage). Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage).
For database mode, see [Database](../configure/database). Refer to [Database](../configure/database) for database modes.
For migration behavior and commands, see [Migrations](../configure/migrations). Learn about migration behavior and commands in [Migrations](../configure/migrations).
4. Run DB 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_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \ -e API_KEY=none \
-e BASE_URL=http://localhost:3003 \ -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 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 | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
| `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path | | `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path |
## Detailed Reference
## Client Runtime and Feature Flags ## Client Runtime and Feature Flags
### NEXT_PUBLIC_NODE_ENV ### NEXT_PUBLIC_NODE_ENV

View file

@ -31,6 +31,8 @@ title: Stack
- State sync: request-based today (not realtime push updates) - State sync: request-based today (not realtime push updates)
- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters - 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`) - 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 - 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 - 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', canvas: '@napi-rs/canvas',
}, },
}, },
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static"], serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
outputFileTracingIncludes: { outputFileTracingIncludes: {
'/api/audiobook': [ '/api/audiobook': [
'./node_modules/ffmpeg-static/ffmpeg', './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_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID;
runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY; 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 }); 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'); setError('Please enter a valid email address');
return; return;
} }
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
const { strength } = validatePassword(password); const { strength } = validatePassword(password);
if (strength < 3) { if (strength < 3) {
setError('Password is too weak'); setError('Password is too weak');

View file

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

View file

@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
const exists = chapters.length > 0 || hasComplete || settings !== null; const exists = chapters.length > 0 || hasComplete || settings !== null;
if (!exists) { 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))); await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
return NextResponse.json({ return NextResponse.json({
chapters: [], chapters: [],

View file

@ -8,57 +8,15 @@ import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobst
import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews'; import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { isS3Configured } from '@/lib/server/s3'; import { isS3Configured } from '@/lib/server/s3';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic'; 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) { export async function GET(req: NextRequest) {
try { try {
if (!isS3Configured()) return s3NotConfiguredResponse(); const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const ctxOrRes = await requireAuthContext(req); const { doc, testNamespace, id } = validation;
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 presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`;
const fallbackUrl = `/api/documents/blob/preview/fallback?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 { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { isS3Configured } from '@/lib/server/s3'; import { isS3Configured } from '@/lib/server/s3';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic'; 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) { export async function GET(req: NextRequest) {
try { try {
if (!isS3Configured()) return s3NotConfiguredResponse(); const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const ctxOrRes = await requireAuthContext(req); const { doc, testNamespace, id } = validation;
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 fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;
const preview = await ensureDocumentPreview( 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 idsParam = url.searchParams.get('ids');
const targetIds = idsParam const targetIds = idsParam
? idsParam ? idsParam
.split(',') .split(',')
.map((id) => id.trim().toLowerCase()) .map((id) => id.trim().toLowerCase())
.filter((id) => isValidDocumentId(id)) .filter((id) => isValidDocumentId(id))
: null; : null;
if (idsParam && (!targetIds || targetIds.length === 0)) { if (idsParam && (!targetIds || targetIds.length === 0)) {
@ -282,7 +282,7 @@ export async function DELETE(req: NextRequest) {
await deleteDocumentBlob(id, testNamespace); await deleteDocumentBlob(id, testNamespace);
} catch (error) { } catch (error) {
if (!isMissingBlobError(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 isAnonymous = Boolean(session.user.isAnonymous);
const ip = getClientIp(req); const ip = getClientIp(req);
const device = isAnonymous && ttsRateLimitEnabled ? getOrCreateDeviceId(req) : null; const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const result = await rateLimiter.getCurrentUsage( 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 { getClientIp } from '@/lib/server/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; 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 CustomVoice = string;
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & { type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
voice: SpeechCreateParams['voice'] | CustomVoice; voice: SpeechCreateParams['voice'] | CustomVoice;
@ -210,9 +216,7 @@ export async function POST(req: NextRequest) {
}, },
}); });
if (didCreateDeviceIdCookie && deviceIdToSet) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
setDeviceIdCookie(response, deviceIdToSet);
}
return response; return response;
} }
@ -286,9 +290,7 @@ export async function POST(req: NextRequest) {
} }
}); });
if (didCreateDeviceIdCookie && deviceIdToSet) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
setDeviceIdCookie(response, deviceIdToSet);
}
return response; return response;
} }
@ -304,9 +306,7 @@ export async function POST(req: NextRequest) {
} }
}); });
if (didCreateDeviceIdCookie && deviceIdToSet) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
setDeviceIdCookie(response, deviceIdToSet);
}
return response; return response;
} }
@ -338,9 +338,7 @@ export async function POST(req: NextRequest) {
} }
}); });
if (didCreateDeviceIdCookie && deviceIdToSet) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
setDeviceIdCookie(response, deviceIdToSet);
}
return response; return response;
} finally { } finally {
@ -392,9 +390,7 @@ export async function POST(req: NextRequest) {
} }
}); });
if (didCreateDeviceIdCookie && deviceIdToSet) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
setDeviceIdCookie(response, deviceIdToSet);
}
return response; return response;
} catch (error) { } catch (error) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,8 +19,34 @@ interface DocumentPreviewProps {
doc: DocumentListDocument; doc: DocumentListDocument;
} }
const MAX_TEXT_PREVIEW_CACHE = 100;
const textPreviewCache = new Map<string, string>(); 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) { export function DocumentPreview({ doc }: DocumentPreviewProps) {
const isPDF = doc.type === 'pdf'; const isPDF = doc.type === 'pdf';
const isEPUB = doc.type === 'epub'; const isEPUB = doc.type === 'epub';
@ -71,7 +97,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
return; return;
} }
const cachedText = textPreviewCache.get(previewKey); const cachedText = textPreviewCacheGet(previewKey);
if (cachedText) { if (cachedText) {
setTextPreview(cachedText); setTextPreview(cachedText);
setImagePreview(null); setImagePreview(null);
@ -154,7 +180,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
signal: controller.signal, signal: controller.signal,
}); });
if (cancelled) return; if (cancelled) return;
textPreviewCache.set(previewKey, snippet); textPreviewCacheSet(previewKey, snippet);
setTextPreview(snippet); setTextPreview(snippet);
setImagePreview(null); setImagePreview(null);
return; return;
@ -238,7 +264,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
doc.id, doc.id,
Number(doc.lastModified), Number(doc.lastModified),
previewKey, previewKey,
).catch(() => {}); ).catch(() => { });
}} }}
/> />
{isImageReady ? <div className="absolute inset-0 bg-gradient-to-t from-black/35 via-black/0 to-black/15" /> : null} {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); return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener);
}, [checkAndMaybePrompt]); }, [checkAndMaybePrompt]);
const title = useMemo(() => `Upload your local documents?`, []); const title = 'Upload your local documents?';
const handleSkip = useCallback(async () => { const handleSkip = useCallback(async () => {
await updateAppConfig({ documentsMigrationPrompted: true }); await updateAppConfig({ documentsMigrationPrompted: true });
@ -124,7 +124,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]); const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null; const stored = uploaded[0] ?? null;
if (stored) { if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
} }
} else if (doc.type === 'epub') { } else if (doc.type === 'epub') {
const full = epubById.get(doc.id) ?? null; const full = epubById.get(doc.id) ?? null;
@ -137,7 +137,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]); const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null; const stored = uploaded[0] ?? null;
if (stored) { if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
} }
} else { } else {
const full = htmlById.get(doc.id) ?? null; const full = htmlById.get(doc.id) ?? null;
@ -150,7 +150,7 @@ export function DexieMigrationModal() {
const uploaded = await uploadDocuments([file]); const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null; const stored = uploaded[0] ?? null;
if (stored) { 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 { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; 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 { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
@ -64,7 +64,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => { const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (!authEnabled) return; if (!authEnabled || sessionKey === 'no-session') return;
const syncedPatch: SyncedPreferencesPatch = {}; const syncedPatch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) { for (const key of SYNCED_PREFERENCE_KEYS) {
@ -74,8 +74,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value; (syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
} }
if (Object.keys(syncedPatch).length === 0) return; if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch); scheduleUserPreferencesSync(syncedPatch, sessionKey);
}, [authEnabled]); }, [authEnabled, sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => {
return () => {
cancelPendingPreferenceSync();
};
}, [sessionKey]);
const buildSyncedPreferencePatch = useCallback(( const buildSyncedPreferencePatch = useCallback((
source: Partial<AppConfigValues>, source: Partial<AppConfigValues>,
@ -152,35 +159,44 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
void run(); void run();
}, [isDBReady]); }, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async () => { const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady || !authEnabled) return; if (!isDBReady || !authEnabled) return;
try { try {
const remote = await getUserPreferences(); const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return; if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return; if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>); await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) { } catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error); console.warn('Failed to load synced preferences:', error);
} }
}, [isDBReady, authEnabled]); }, [isDBReady, authEnabled]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !authEnabled || isSessionPending) return; 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); console.warn('Synced preferences refresh failed:', error);
}); });
return () => controller.abort();
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !authEnabled) return; if (!isDBReady || !authEnabled) return;
let activeController: AbortController | null = null;
const onFocus = () => { 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); console.warn('Focus synced preferences refresh failed:', error);
}); });
}; };
window.addEventListener('focus', onFocus); window.addEventListener('focus', onFocus);
return () => { return () => {
window.removeEventListener('focus', onFocus); window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort();
}; };
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
@ -206,9 +222,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey; didAttemptInitialPreferenceSeedForSession.current = sessionKey;
const controller = new AbortController();
const run = async () => { const run = async () => {
try { try {
const remote = await getUserPreferences(); const remote = await getUserPreferences({ signal: controller.signal });
if (remote?.hasStoredPreferences) return; if (remote?.hasStoredPreferences) return;
// Seed only user-customized (non-default) values. This prevents fresh/default // 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 }); const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
if (Object.keys(patch).length === 0) return; if (Object.keys(patch).length === 0) return;
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() }); await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal });
} catch (error) { } catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to seed initial synced preferences from local Dexie:', error); console.warn('Failed to seed initial synced preferences from local Dexie:', error);
} }
}; };
run().catch((error) => { run().catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Initial synced preferences seed failed:', error); console.warn('Initial synced preferences seed failed:', error);
}); });
return () => controller.abort();
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]); }, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape // Destructure for convenience and to match context shape

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; 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 { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents';
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
@ -27,9 +27,7 @@ interface DocumentContextType {
refreshDocuments: () => Promise<void>; refreshDocuments: () => Promise<void>;
clearPDFs: () => Promise<void>;
clearEPUBs: () => Promise<void>;
clearHTML: () => Promise<void>;
} }
const DocumentContext = createContext<DocumentContextType | undefined>(undefined); 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 removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]); const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const clearByType = useCallback(async (type: DocumentType) => { // Removed unused clear functions
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]);
return ( return (
<DocumentContext.Provider value={{ <DocumentContext.Provider value={{
@ -147,9 +133,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
removeHTMLDocument, removeHTMLDocument,
isHTMLLoading: isLoading, isHTMLLoading: isLoading,
refreshDocuments, refreshDocuments,
clearPDFs,
clearEPUBs,
clearHTML,
}}> }}>
{children} {children}
</DocumentContext.Provider> </DocumentContext.Provider>

View file

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

View file

@ -33,18 +33,24 @@ export function ensureSystemUserExists(userId: string) {
const drizzleDb = getDrizzleDB(); const drizzleDb = getDrizzleDB();
try { try {
if (dbIsPostgres) { 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` drizzleDb.execute(sql`
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous) INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false) VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false)
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
`).catch(() => { /* table may not exist yet */ }); `).then(() => {
seededUserIds.add(userId);
}).catch(() => { /* table may not exist yet */ });
} else { } else {
// better-sqlite3 driver is fully synchronous — no Promise returned.
drizzleDb.run(sql` drizzleDb.run(sql`
INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) 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) VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0)
`); `);
seededUserIds.add(userId);
} }
seededUserIds.add(userId);
} catch { } catch {
// Silently ignore the user table may not exist yet on first boot before migrations run. // 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 = { type PendingPreferenceSync = {
patch: SyncedPreferencesPatch; patch: SyncedPreferencesPatch;
timer: ReturnType<typeof setTimeout> | null; timer: ReturnType<typeof setTimeout> | null;
sessionId: string | null;
}; };
const pendingPreferenceSync: PendingPreferenceSync = { const pendingPreferenceSync: PendingPreferenceSync = {
patch: {}, patch: {},
timer: null, 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)); Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
pendingPreferenceSync.sessionId = sessionId;
if (pendingPreferenceSync.timer) { if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer); clearTimeout(pendingPreferenceSync.timer);
} }
const capturedSessionId = sessionId;
pendingPreferenceSync.timer = setTimeout(async () => { pendingPreferenceSync.timer = setTimeout(async () => {
// If the session changed between scheduling and firing, discard.
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
const payload = { ...pendingPreferenceSync.patch }; const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {}; pendingPreferenceSync.patch = {};
pendingPreferenceSync.timer = null; pendingPreferenceSync.timer = null;
if (Object.keys(payload).length === 0) return; 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 { try {
await putUserPreferences(payload); await putUserPreferences(payload, { signal: activeSyncController.signal });
} catch (error) { } catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to sync user preferences:', error); console.warn('Failed to sync user preferences:', error);
} finally {
activeSyncController = null;
} }
}, debounceMs); }, debounceMs);
} }