From 4d13b5b8218bfc27e0abd137b8047feebc1e6705 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 15 Feb 2026 15:13:25 -0700 Subject: [PATCH] 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. --- .npmrc | 1 + docs-site/docs/configure/database.md | 4 +- docs-site/docs/configure/migrations.md | 30 +++++-- docs-site/docs/deploy/local-development.md | 10 +-- docs-site/docs/docker-quick-start.md | 2 +- .../docs/reference/environment-variables.md | 2 - docs-site/docs/reference/stack.md | 2 + next.config.ts | 2 +- scripts/openreader-entrypoint.mjs | 4 +- src/app/(app)/signup/page.tsx | 4 + src/app/api/account/delete/route.ts | 6 +- src/app/api/audiobook/status/route.ts | 2 +- .../documents/blob/preview/ensure/route.ts | 50 +---------- .../documents/blob/preview/presign/route.ts | 50 +---------- src/app/api/documents/blob/preview/utils.ts | 87 +++++++++++++++++++ src/app/api/documents/route.ts | 8 +- src/app/api/rate-limit/status/route.ts | 2 +- src/app/api/tts/route.ts | 26 +++--- src/app/api/user/claim/route.ts | 23 ++--- src/app/api/user/state/progress/route.ts | 19 ++-- src/app/globals.css | 10 +-- src/app/providers.tsx | 2 +- src/components/SettingsModal.tsx | 15 ++-- src/components/doclist/DocumentPreview.tsx | 32 ++++++- .../documents/DexieMigrationModal.tsx | 8 +- src/contexts/ConfigContext.tsx | 42 ++++++--- src/contexts/DocumentContext.tsx | 24 +---- src/contexts/PDFContext.tsx | 26 +++++- src/db/index.ts | 10 ++- src/lib/client-user-state.ts | 43 ++++++++- 30 files changed, 328 insertions(+), 218 deletions(-) create mode 100644 .npmrc create mode 100644 src/app/api/documents/blob/preview/utils.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index 88c79f5..eb8e89f 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -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` diff --git a/docs-site/docs/configure/migrations.md b/docs-site/docs/configure/migrations.md index 934472f..9cb3234 100644 --- a/docs-site/docs/configure/migrations.md +++ b/docs-site/docs/configure/migrations.md @@ -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. + ```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 ```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. +::: + diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index e123369..36d5fc4 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -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) @@ -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. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index fdf3239..dd21287 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -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= \ + -e AUTH_SECRET=$(openssl rand -base64 32) \ ghcr.io/richardr1126/openreader-webui:latest ``` diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index b97350a..5504bc1 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -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 diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 136b170..cc45fa2 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -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 diff --git a/next.config.ts b/next.config.ts index bcf83cd..de6f7dd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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', diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 23f49c2..fa9b235 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -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 }); diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index 6bc4efe..a6f3f6c 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -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'); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index fd7e0b0..cf5d0a8 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -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: {}, }); diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index b88c458..3155fd4 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -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: [], diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index 8d417ad..0bdae9b 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -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)}`; diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts index d52e0f5..ef280d5 100644 --- a/src/app/api/documents/blob/preview/presign/route.ts +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -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( diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts new file mode 100644 index 0000000..d8d6ed2 --- /dev/null +++ b/src/app/api/documents/blob/preview/utils.ts @@ -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 { + 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 + }; +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 5492b18..0e64479 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -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); } } diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 4274a88..021775b 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -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( { diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 8fc1453..de3c167 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -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 & { 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) { diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 9acb8db..344c88d 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -25,22 +25,13 @@ async function checkClaimMigrationReadiness(): Promise { 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), diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index 99666d3..4eb6f9a 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -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({ diff --git a/src/app/globals.css b/src/app/globals.css index 2e72e40..881ff04 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/providers.tsx b/src/app/providers.tsx index efbaf11..d75257d 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 265523b..e69d499 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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 }) {

{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.'}

diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 699adb9..992dd33 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -19,8 +19,34 @@ interface DocumentPreviewProps { doc: DocumentListDocument; } +const MAX_TEXT_PREVIEW_CACHE = 100; const textPreviewCache = new Map(); +/** 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 ?
: null} diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index ff9a493..7e3226f 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -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(() => { }); } } } diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index bb68f2b..c5d2c5e 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -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) => { - 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)[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, @@ -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); } 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 diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index e4e178e..6290578 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -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; - clearPDFs: () => Promise; - clearEPUBs: () => Promise; - clearHTML: () => Promise; + } const DocumentContext = createContext(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 ( {children} diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 8221216..bdcabf9 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -156,6 +156,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { const loadSeqRef = useRef(0); const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); + // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. + const docLoadSeqRef = useRef(0); + const docLoadAbortRef = useRef(null); + useEffect(() => { pdfDocumentRef.current = pdfDocument; }, [pdfDocument]); @@ -317,6 +321,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { * @returns {Promise} */ const setCurrentDocument = useCallback(async (id: string): Promise => { + // --- 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); } diff --git a/src/db/index.ts b/src/db/index.ts index 6028f95..599b9f2 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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. } diff --git a/src/lib/client-user-state.ts b/src/lib/client-user-state.ts index 5bc8823..bf654a4 100644 --- a/src/lib/client-user-state.ts +++ b/src/lib/client-user-state.ts @@ -56,30 +56,69 @@ export async function putUserPreferences( type PendingPreferenceSync = { patch: SyncedPreferencesPatch; timer: ReturnType | 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); }