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:
parent
8e4f0b0ca9
commit
4d13b5b821
30 changed files with 328 additions and 218 deletions
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
node-linker=hoisted
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -76,20 +76,34 @@ pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
|
|||
|
||||
## Generate migrations
|
||||
|
||||
`pnpm generate` creates migration files for both configs in one run:
|
||||
`pnpm generate` is a two-phase script for contributors and schema changes:
|
||||
|
||||
- `drizzle.config.sqlite.ts`
|
||||
- `drizzle.config.pg.ts`
|
||||
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
|
||||
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
|
||||
|
||||
:::note
|
||||
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
|
||||
:::
|
||||
|
||||
### Schema ownership
|
||||
|
||||
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
|
||||
|
||||
- `src/db/schema_auth_sqlite.ts`
|
||||
- `src/db/schema_auth_postgres.ts`
|
||||
|
||||
App-specific tables are manually maintained in the standard Drizzle schema files:
|
||||
|
||||
- `src/db/schema_sqlite.ts`
|
||||
- `src/db/schema_postgres.ts`
|
||||
|
||||
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
|
||||
|
||||
<Tabs groupId="generate-migration-commands">
|
||||
<TabItem value="project-script" label="Project Script" default>
|
||||
|
||||
```bash
|
||||
# Generate migration files for both SQLite and Postgres outputs
|
||||
# Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
|
||||
pnpm generate
|
||||
```
|
||||
|
||||
|
|
@ -97,13 +111,17 @@ pnpm generate
|
|||
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
||||
|
||||
```bash
|
||||
# Generate SQLite migrations
|
||||
# Generate SQLite migrations only (skips Better Auth CLI)
|
||||
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
|
||||
|
||||
# Generate Postgres migrations
|
||||
# Generate Postgres migrations only (skips Better Auth CLI)
|
||||
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
|
||||
```
|
||||
|
||||
:::warning
|
||||
Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first.
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ npm install -g pnpm
|
|||
```
|
||||
|
||||
- A reachable TTS API server
|
||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required)
|
||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required unless using external S3 storage)
|
||||
|
||||
<Tabs groupId="seaweedfs-install">
|
||||
<TabItem value="macos" label="macOS" default>
|
||||
|
|
@ -93,10 +93,10 @@ Optional:
|
|||
For all environment variables, see [Environment Variables](../reference/environment-variables).
|
||||
:::
|
||||
|
||||
For app/auth behavior, see [Auth](../configure/auth).
|
||||
For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage).
|
||||
For database mode, see [Database](../configure/database).
|
||||
For migration behavior and commands, see [Migrations](../configure/migrations).
|
||||
See [Auth](../configure/auth) for app/auth behavior.
|
||||
Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage).
|
||||
Refer to [Database](../configure/database) for database modes.
|
||||
Learn about migration behavior and commands in [Migrations](../configure/migrations).
|
||||
|
||||
4. Run DB migrations.
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ docker run --name openreader-webui \
|
|||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||
-e API_KEY=none \
|
||||
-e BASE_URL=http://localhost:3003 \
|
||||
-e AUTH_SECRET=<paste_the_output_of_openssl_here> \
|
||||
-e AUTH_SECRET=$(openssl rand -base64 32) \
|
||||
ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
87
src/app/api/documents/blob/preview/utils.ts
Normal file
87
src/app/api/documents/blob/preview/utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import { isAuthEnabled } from '@/lib/server/auth-config';
|
|||
import { getClientIp } from '@/lib/server/request-ip';
|
||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id';
|
||||
|
||||
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
|
||||
if (didCreate && deviceId) {
|
||||
setDeviceIdCookie(response, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
type CustomVoice = string;
|
||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||
|
|
@ -210,9 +216,7 @@ export async function POST(req: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||
setDeviceIdCookie(response, deviceIdToSet);
|
||||
}
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
@ -286,9 +290,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
});
|
||||
|
||||
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||
setDeviceIdCookie(response, deviceIdToSet);
|
||||
}
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
@ -304,9 +306,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
});
|
||||
|
||||
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||
setDeviceIdCookie(response, deviceIdToSet);
|
||||
}
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
@ -338,9 +338,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
});
|
||||
|
||||
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||
setDeviceIdCookie(response, deviceIdToSet);
|
||||
}
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
|
|
@ -392,9 +390,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
});
|
||||
|
||||
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||
setDeviceIdCookie(response, deviceIdToSet);
|
||||
}
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -25,22 +25,13 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
|||
async function getClaimableCounts(
|
||||
unclaimedUserId: string,
|
||||
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> {
|
||||
const [docCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(documents)
|
||||
.where(eq(documents.userId, unclaimedUserId));
|
||||
const [bookCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(audiobooks)
|
||||
.where(eq(audiobooks.userId, unclaimedUserId));
|
||||
const [preferencesCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, unclaimedUserId));
|
||||
const [progressCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(userDocumentProgress)
|
||||
.where(eq(userDocumentProgress.userId, unclaimedUserId));
|
||||
const [[docCount], [bookCount], [preferencesCount], [progressCount]] =
|
||||
await Promise.all([
|
||||
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
documents: Number(docCount?.count ?? 0),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -296,10 +296,10 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
try {
|
||||
if (deleteDocsMode === 'user') {
|
||||
await deleteDocuments();
|
||||
await refreshDocuments().catch(() => {});
|
||||
await refreshDocuments().catch(() => { });
|
||||
} else if (deleteDocsMode === 'unclaimed') {
|
||||
await deleteDocuments({ scope: 'unclaimed' });
|
||||
await refreshDocuments().catch(() => {});
|
||||
await refreshDocuments().catch(() => { });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
|
|
@ -366,8 +366,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const userDeleteTitle = authEnabled ? (isAnonymous ? 'Delete Anonymous Docs' : 'Delete All User Docs') : 'Delete Server Docs';
|
||||
const userDeleteMessage = authEnabled
|
||||
? (isAnonymous
|
||||
? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.'
|
||||
: 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.')
|
||||
? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.'
|
||||
: 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.')
|
||||
: 'Are you sure you want to delete all documents from the server? This action cannot be undone.';
|
||||
|
||||
const [unclaimedCounts, setUnclaimedCounts] = useState<{ documents: number; audiobooks: number } | null>(null);
|
||||
|
|
@ -386,7 +386,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}, [authEnabled, session?.user]);
|
||||
|
||||
return (
|
||||
|
|
@ -661,7 +661,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
setModelValue('kokoro');
|
||||
setCustomModelInput('');
|
||||
setLocalTTSInstructions('');
|
||||
setLocalTTSInstructions('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
|
|
@ -876,8 +875,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<p className="text-sm text-muted mb-3">
|
||||
{session?.user?.isAnonymous
|
||||
? (allowAnonymousAuthSessions
|
||||
? 'You are using an anonymous session. Sign up to save your progress permanently.'
|
||||
: 'Anonymous sessions are disabled. Please sign in or create an account.')
|
||||
? 'You are using an anonymous session. Sign up to save your progress permanently.'
|
||||
: 'Anonymous sessions are disabled. Please sign in or create an account.')
|
||||
: 'No active session. Please sign in or create an account.'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
|
|
|||
|
|
@ -19,8 +19,34 @@ interface DocumentPreviewProps {
|
|||
doc: DocumentListDocument;
|
||||
}
|
||||
|
||||
const MAX_TEXT_PREVIEW_CACHE = 100;
|
||||
const textPreviewCache = new Map<string, string>();
|
||||
|
||||
/** Read from cache and promote entry to most-recently-used. */
|
||||
function textPreviewCacheGet(key: string): string | undefined {
|
||||
const value = textPreviewCache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Re-insert to move to end (most-recently-used)
|
||||
textPreviewCache.delete(key);
|
||||
textPreviewCache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Write to cache, evicting the least-recently-used entry when over the cap. */
|
||||
function textPreviewCacheSet(key: string, value: string): void {
|
||||
// If the key already exists, delete first so re-insertion moves it to the end
|
||||
if (textPreviewCache.has(key)) {
|
||||
textPreviewCache.delete(key);
|
||||
}
|
||||
textPreviewCache.set(key, value);
|
||||
if (textPreviewCache.size > MAX_TEXT_PREVIEW_CACHE) {
|
||||
// Map keys iterate in insertion order; first key is the LRU entry
|
||||
const oldest = textPreviewCache.keys().next().value;
|
||||
if (oldest !== undefined) textPreviewCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
||||
const isPDF = doc.type === 'pdf';
|
||||
const isEPUB = doc.type === 'epub';
|
||||
|
|
@ -71,7 +97,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
const cachedText = textPreviewCache.get(previewKey);
|
||||
const cachedText = textPreviewCacheGet(previewKey);
|
||||
if (cachedText) {
|
||||
setTextPreview(cachedText);
|
||||
setImagePreview(null);
|
||||
|
|
@ -154,7 +180,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
signal: controller.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
textPreviewCache.set(previewKey, snippet);
|
||||
textPreviewCacheSet(previewKey, snippet);
|
||||
setTextPreview(snippet);
|
||||
setImagePreview(null);
|
||||
return;
|
||||
|
|
@ -238,7 +264,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
doc.id,
|
||||
Number(doc.lastModified),
|
||||
previewKey,
|
||||
).catch(() => {});
|
||||
).catch(() => { });
|
||||
}}
|
||||
/>
|
||||
{isImageReady ? <div className="absolute inset-0 bg-gradient-to-t from-black/35 via-black/0 to-black/15" /> : null}
|
||||
|
|
|
|||
|
|
@ -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(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||
import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
|
||||
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
|
|
@ -64,7 +64,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
|
||||
|
||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||
if (!authEnabled) return;
|
||||
if (!authEnabled || sessionKey === 'no-session') return;
|
||||
|
||||
const syncedPatch: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
|
|
@ -74,8 +74,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
if (Object.keys(syncedPatch).length === 0) return;
|
||||
scheduleUserPreferencesSync(syncedPatch);
|
||||
}, [authEnabled]);
|
||||
scheduleUserPreferencesSync(syncedPatch, sessionKey);
|
||||
}, [authEnabled, sessionKey]);
|
||||
|
||||
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelPendingPreferenceSync();
|
||||
};
|
||||
}, [sessionKey]);
|
||||
|
||||
const buildSyncedPreferencePatch = useCallback((
|
||||
source: Partial<AppConfigValues>,
|
||||
|
|
@ -152,35 +159,44 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
void run();
|
||||
}, [isDBReady]);
|
||||
|
||||
const refreshSyncedPreferencesFromServer = useCallback(async () => {
|
||||
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
|
||||
if (!isDBReady || !authEnabled) return;
|
||||
try {
|
||||
const remote = await getUserPreferences();
|
||||
const remote = await getUserPreferences({ signal });
|
||||
if (!remote?.hasStoredPreferences) return;
|
||||
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
|
||||
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Failed to load synced preferences:', error);
|
||||
}
|
||||
}, [isDBReady, authEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !authEnabled || isSessionPending) return;
|
||||
refreshSyncedPreferencesFromServer().catch((error) => {
|
||||
const controller = new AbortController();
|
||||
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Synced preferences refresh failed:', error);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !authEnabled) return;
|
||||
let activeController: AbortController | null = null;
|
||||
const onFocus = () => {
|
||||
refreshSyncedPreferencesFromServer().catch((error) => {
|
||||
if (activeController) activeController.abort();
|
||||
activeController = new AbortController();
|
||||
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Focus synced preferences refresh failed:', error);
|
||||
});
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
if (activeController) activeController.abort();
|
||||
};
|
||||
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
|
|
@ -206,9 +222,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
|
||||
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const remote = await getUserPreferences();
|
||||
const remote = await getUserPreferences({ signal: controller.signal });
|
||||
if (remote?.hasStoredPreferences) return;
|
||||
|
||||
// Seed only user-customized (non-default) values. This prevents fresh/default
|
||||
|
|
@ -216,15 +234,19 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() });
|
||||
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal });
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Initial synced preferences seed failed:', error);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
|
||||
|
||||
// Destructure for convenience and to match context shape
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||
import type { BaseDocument, DocumentType } from '@/types/documents';
|
||||
import type { BaseDocument } from '@/types/documents';
|
||||
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents';
|
||||
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
|
|
@ -27,9 +27,7 @@ interface DocumentContextType {
|
|||
|
||||
refreshDocuments: () => Promise<void>;
|
||||
|
||||
clearPDFs: () => Promise<void>;
|
||||
clearEPUBs: () => Promise<void>;
|
||||
clearHTML: () => Promise<void>;
|
||||
|
||||
}
|
||||
|
||||
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
||||
|
|
@ -118,19 +116,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]);
|
||||
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
|
||||
|
||||
const clearByType = useCallback(async (type: DocumentType) => {
|
||||
const toDelete = (docs ?? []).filter((d) => d.type === type).map((d) => d.id);
|
||||
if (toDelete.length) {
|
||||
await deleteDocuments({ ids: toDelete });
|
||||
}
|
||||
// Evict cache and update local list
|
||||
await Promise.allSettled(toDelete.map((id) => Promise.all([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)])));
|
||||
setDocs((prev) => (prev ?? []).filter((d) => d.type !== type));
|
||||
}, [docs]);
|
||||
|
||||
const clearPDFs = useCallback(async () => clearByType('pdf'), [clearByType]);
|
||||
const clearEPUBs = useCallback(async () => clearByType('epub'), [clearByType]);
|
||||
const clearHTML = useCallback(async () => clearByType('html'), [clearByType]);
|
||||
// Removed unused clear functions
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{
|
||||
|
|
@ -147,9 +133,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
removeHTMLDocument,
|
||||
isHTMLLoading: isLoading,
|
||||
refreshDocuments,
|
||||
clearPDFs,
|
||||
clearEPUBs,
|
||||
clearHTML,
|
||||
|
||||
}}>
|
||||
{children}
|
||||
</DocumentContext.Provider>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const loadSeqRef = useRef(0);
|
||||
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
|
||||
|
||||
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
|
||||
const docLoadSeqRef = useRef(0);
|
||||
const docLoadAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pdfDocumentRef.current = pdfDocument;
|
||||
}, [pdfDocument]);
|
||||
|
|
@ -317,6 +321,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||
// --- race-condition guard ---
|
||||
const seq = ++docLoadSeqRef.current;
|
||||
docLoadAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
docLoadAbortRef.current = controller;
|
||||
|
||||
try {
|
||||
// Reset any state tied to the previously loaded PDF. This prevents calling
|
||||
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
|
||||
|
|
@ -335,13 +345,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
setCurrDocName(undefined);
|
||||
setCurrDocData(undefined);
|
||||
|
||||
const meta = await getDocumentMetadata(id);
|
||||
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
||||
if (seq !== docLoadSeqRef.current) return; // stale
|
||||
if (!meta) {
|
||||
console.error('Document not found on server');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await ensureCachedDocument(meta);
|
||||
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
||||
if (seq !== docLoadSeqRef.current) return; // stale
|
||||
if (doc.type !== 'pdf') {
|
||||
console.error('Document is not a PDF');
|
||||
return;
|
||||
|
|
@ -352,7 +364,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||
setCurrDocData(doc.data.slice(0));
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.error('Failed to get document:', error);
|
||||
} finally {
|
||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||
if (docLoadAbortRef.current === controller) {
|
||||
docLoadAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
|
||||
|
||||
|
|
@ -364,6 +382,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
pdfDocGenerationRef.current += 1;
|
||||
pdfDocumentRef.current = undefined;
|
||||
loadSeqRef.current += 1;
|
||||
// Invalidate any in-flight setCurrentDocument load.
|
||||
docLoadSeqRef.current += 1;
|
||||
docLoadAbortRef.current?.abort();
|
||||
docLoadAbortRef.current = null;
|
||||
if (emptyRetryRef.current?.timer) {
|
||||
clearTimeout(emptyRetryRef.current.timer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,30 +56,69 @@ export async function putUserPreferences(
|
|||
type PendingPreferenceSync = {
|
||||
patch: SyncedPreferencesPatch;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
const pendingPreferenceSync: PendingPreferenceSync = {
|
||||
patch: {},
|
||||
timer: null,
|
||||
sessionId: null,
|
||||
};
|
||||
|
||||
export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void {
|
||||
let activeSyncController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Cancel any pending debounced preference sync and abort in-flight requests.
|
||||
* Call this on session change / sign-out to prevent cross-account writes.
|
||||
*/
|
||||
export function cancelPendingPreferenceSync(): void {
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
pendingPreferenceSync.timer = null;
|
||||
}
|
||||
pendingPreferenceSync.patch = {};
|
||||
pendingPreferenceSync.sessionId = null;
|
||||
|
||||
if (activeSyncController) {
|
||||
activeSyncController.abort();
|
||||
activeSyncController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleUserPreferencesSync(
|
||||
patch: SyncedPreferencesPatch,
|
||||
sessionId: string,
|
||||
debounceMs: number = 600,
|
||||
): void {
|
||||
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
|
||||
pendingPreferenceSync.sessionId = sessionId;
|
||||
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
}
|
||||
|
||||
const capturedSessionId = sessionId;
|
||||
|
||||
pendingPreferenceSync.timer = setTimeout(async () => {
|
||||
// If the session changed between scheduling and firing, discard.
|
||||
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
|
||||
|
||||
const payload = { ...pendingPreferenceSync.patch };
|
||||
pendingPreferenceSync.patch = {};
|
||||
pendingPreferenceSync.timer = null;
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
// Abort any previous in-flight sync and create a fresh controller.
|
||||
if (activeSyncController) activeSyncController.abort();
|
||||
activeSyncController = new AbortController();
|
||||
|
||||
try {
|
||||
await putUserPreferences(payload);
|
||||
await putUserPreferences(payload, { signal: activeSyncController.signal });
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Failed to sync user preferences:', error);
|
||||
} finally {
|
||||
activeSyncController = null;
|
||||
}
|
||||
}, debounceMs);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue