fix: reduce pdf flicker and harden tts
- Smooth react-pdf page turns with stable canvas staging and fade-in - Retry transient empty page text extraction during fast page turns - Avoid noisy TTS preload toasts, guard requests when quota is exhausted, and improve Howler retry/unload behavior to prevent pool exhaustion - Enforce auth FK cascade deletes and tighten rate-limiter bucket updates using affected-row checks
This commit is contained in:
parent
c57b913cb5
commit
8189087ad9
10 changed files with 902 additions and 116 deletions
6
drizzle_pg/0001_futuristic_harpoon.sql
Normal file
6
drizzle_pg/0001_futuristic_harpoon.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
592
drizzle_pg/meta/0001_snapshot.json
Normal file
592
drizzle_pg/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
{
|
||||
"id": "62e82369-d624-4cc1-a482-7ea5fbc7275b",
|
||||
"prevId": "5ac6552c-824b-4b79-8cb6-f0ec3d121031",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audiobook_chapters": {
|
||||
"name": "audiobook_chapters",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"book_id": {
|
||||
"name": "book_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"chapter_index": {
|
||||
"name": "chapter_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"audiobook_chapters_book_id_audiobooks_id_fk": {
|
||||
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
|
||||
"tableFrom": "audiobook_chapters",
|
||||
"tableTo": "audiobooks",
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audiobooks": {
|
||||
"name": "audiobooks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_path": {
|
||||
"name": "cover_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_modified": {
|
||||
"name": "last_modified",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"documents_id_user_id_pk": {
|
||||
"name": "documents_id_user_id_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_anonymous": {
|
||||
"name": "is_anonymous",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_tts_chars": {
|
||||
"name": "user_tts_chars",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"char_count": {
|
||||
"name": "char_count",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_user_tts_chars_date": {
|
||||
"name": "idx_user_tts_chars_date",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "date",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"user_tts_chars_user_id_date_pk": {
|
||||
"name": "user_tts_chars_user_id_date_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"date"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1769393893675,
|
||||
"tag": "0000_military_nemesis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1769404444945,
|
||||
"tag": "0001_futuristic_harpoon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -119,6 +119,50 @@ body {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/*
|
||||
* PDF page turn smoothing
|
||||
* - Keep a stable paint surface (prevents background flash)
|
||||
* - Fade-in newly rendered canvas
|
||||
*/
|
||||
.pdf-page-stage {
|
||||
/* Ensure we always paint something (matches app theme) while react-pdf swaps canvases */
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.pdf-viewer .react-pdf__Page {
|
||||
/* Make page paints more stable and avoid seeing the app background between renders */
|
||||
background: var(--background);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes pdf-canvas-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */
|
||||
.pdf-viewer .react-pdf__message {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pdf-page-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* App shell utility (fullscreen, vertical layout) */
|
||||
.app-shell {
|
||||
--header-height: 3.25rem;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface PDFOnLinkClickArgs {
|
|||
|
||||
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPageRendering, setIsPageRendering] = useState(false);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const { containerWidth, containerHeight } = usePDFResize(containerRef);
|
||||
const sentenceHighlightSeqRef = useRef(0);
|
||||
|
|
@ -75,6 +76,14 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
|
||||
const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
|
||||
|
||||
// Track page turns so we can keep the previous canvas visible until the new one paints.
|
||||
const lastRenderedLayoutKeyRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (layoutKey !== lastRenderedLayoutKeyRef.current) {
|
||||
setIsPageRendering(true);
|
||||
}
|
||||
}, [layoutKey]);
|
||||
|
||||
const clearSentenceHighlightTimeouts = useCallback(() => {
|
||||
for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t);
|
||||
sentenceHighlightTimeoutsRef.current = [];
|
||||
|
|
@ -282,7 +291,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
}, [calculateScale]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col items-center overflow-auto w-full px-6 h-full pdf-viewer"
|
||||
>
|
||||
<Document
|
||||
key={currDocId || 'pdf'}
|
||||
loading={<DocumentSkeleton />}
|
||||
|
|
@ -302,9 +314,9 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
}
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center m-0 z-0"
|
||||
className="flex flex-col items-center m-0 z-0"
|
||||
>
|
||||
<div>
|
||||
<div className="pdf-page-stage" data-rendering={isPageRendering ? 'true' : 'false'}>
|
||||
{viewType === 'scroll' ? (
|
||||
// Scroll mode: render all pages
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -316,6 +328,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
renderTextLayer={i + 1 === currDocPage}
|
||||
className="shadow-lg"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
setIsPageRendering(false);
|
||||
}}
|
||||
onLoadSuccess={(page) => {
|
||||
setPageWidth(page.originalWidth);
|
||||
setPageHeight(page.originalHeight);
|
||||
|
|
@ -334,6 +350,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
renderTextLayer={leftPage === currDocPage}
|
||||
className="shadow-lg"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
setIsPageRendering(false);
|
||||
}}
|
||||
onLoadSuccess={(page) => {
|
||||
setPageWidth(page.originalWidth);
|
||||
setPageHeight(page.originalHeight);
|
||||
|
|
@ -348,6 +368,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
renderTextLayer={rightPage === currDocPage}
|
||||
className="shadow-lg"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
setIsPageRendering(false);
|
||||
}}
|
||||
onLoadSuccess={(page) => {
|
||||
setPageWidth(page.originalWidth);
|
||||
setPageHeight(page.originalHeight);
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ interface PDFContextType {
|
|||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||
|
||||
const CONTINUATION_PREVIEW_CHARS = 600;
|
||||
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
|
||||
const EMPTY_TEXT_MAX_RETRIES = 6;
|
||||
|
||||
/**
|
||||
* PDFProvider Component
|
||||
|
|
@ -150,6 +152,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
// or when react-pdf tears down and recreates its internal worker.
|
||||
const pdfDocGenerationRef = useRef(0);
|
||||
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
|
||||
const loadSeqRef = useRef(0);
|
||||
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pdfDocumentRef.current = pdfDocument;
|
||||
|
|
@ -183,6 +187,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const generation = pdfDocGenerationRef.current;
|
||||
const currentPdf = pdfDocumentRef.current;
|
||||
if (!currentPdf) return;
|
||||
const seq = ++loadSeqRef.current;
|
||||
const pageNumber = currDocPageNumber;
|
||||
|
||||
const existingRetry = emptyRetryRef.current;
|
||||
if (existingRetry?.timer) {
|
||||
clearTimeout(existingRetry.timer);
|
||||
}
|
||||
emptyRetryRef.current =
|
||||
existingRetry && existingRetry.page === pageNumber
|
||||
? { ...existingRetry, timer: null }
|
||||
: null;
|
||||
|
||||
const margins = {
|
||||
header: headerMargin,
|
||||
|
|
@ -228,6 +243,35 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||
return;
|
||||
}
|
||||
if (seq !== loadSeqRef.current || pageNumber !== currDocPageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0;
|
||||
const attempt = prevAttempt + 1;
|
||||
|
||||
// Avoid pushing empty text into TTS immediately; transient empty extractions can happen
|
||||
// during page turns or react-pdf worker churn. Retry a few times before treating it as
|
||||
// a truly blank page.
|
||||
if (attempt <= EMPTY_TEXT_MAX_RETRIES) {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||
return;
|
||||
}
|
||||
if (pageNumber !== currDocPageNumber) {
|
||||
return;
|
||||
}
|
||||
void loadCurrDocText();
|
||||
}, EMPTY_TEXT_RETRY_DELAY_MS);
|
||||
|
||||
emptyRetryRef.current = { page: pageNumber, attempt, timer };
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
emptyRetryRef.current = null;
|
||||
}
|
||||
|
||||
if (text !== currDocText || text === '') {
|
||||
setCurrDocText(text);
|
||||
|
|
@ -277,6 +321,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
|
||||
// or fast refresh.
|
||||
pdfDocGenerationRef.current += 1;
|
||||
loadSeqRef.current += 1;
|
||||
if (emptyRetryRef.current?.timer) {
|
||||
clearTimeout(emptyRetryRef.current.timer);
|
||||
}
|
||||
emptyRetryRef.current = null;
|
||||
pageTextCacheRef.current.clear();
|
||||
setPdfDocument(undefined);
|
||||
setCurrDocPages(undefined);
|
||||
|
|
@ -304,6 +353,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const clearCurrDoc = useCallback(() => {
|
||||
pdfDocGenerationRef.current += 1;
|
||||
pdfDocumentRef.current = undefined;
|
||||
loadSeqRef.current += 1;
|
||||
if (emptyRetryRef.current?.timer) {
|
||||
clearTimeout(emptyRetryRef.current.timer);
|
||||
}
|
||||
emptyRetryRef.current = null;
|
||||
setCurrDocId(undefined);
|
||||
setCurrDocName(undefined);
|
||||
setCurrDocData(undefined);
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const audioContext = useAudioContext();
|
||||
const audioCache = useAudioCache(25);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit();
|
||||
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAutoRateLimit();
|
||||
|
||||
// Add ref for location change handler
|
||||
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||
|
|
@ -820,6 +820,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
return cachedAudio;
|
||||
}
|
||||
|
||||
// If the user is already out of quota, avoid spamming requests.
|
||||
// Cached audio above is still allowed to play.
|
||||
if (isAtLimit) {
|
||||
if (!preload) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Requesting audio for sentence:', sentence);
|
||||
|
||||
|
|
@ -906,28 +915,34 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
// Handle daily quota exceeded (429 + Problem Details code)
|
||||
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
||||
toast.error('Daily TTS limit reached.', {
|
||||
id: 'tts-limit-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 5000,
|
||||
});
|
||||
// Avoid noisy toasts from background preloading; keep the user-facing error for active playback.
|
||||
if (!preload) {
|
||||
toast.error('Daily TTS limit reached.', {
|
||||
id: 'tts-limit-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
triggerRateLimit();
|
||||
refreshRateLimit().catch(console.error);
|
||||
// Do NOT re-throw, just return undefined to stop playback gracefully
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toast.error('Failed to generate audio. Server not responding.', {
|
||||
id: 'tts-api-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 7000,
|
||||
});
|
||||
// Avoid noisy toasts from background preloading.
|
||||
if (!preload) {
|
||||
toast.error('TTS failed. Skipped sentence and paused.', {
|
||||
id: 'tts-api-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 7000,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [
|
||||
|
|
@ -947,7 +962,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
triggerRateLimit,
|
||||
refreshRateLimit,
|
||||
onTTSComplete,
|
||||
onTTSStart
|
||||
onTTSStart,
|
||||
isAtLimit
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -1000,9 +1016,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (preload) {
|
||||
preloadRequests.current.set(sentence, processPromise);
|
||||
// Clean up the map entry once the promise resolves or rejects
|
||||
processPromise.finally(() => {
|
||||
preloadRequests.current.delete(sentence);
|
||||
});
|
||||
void processPromise
|
||||
.finally(() => {
|
||||
preloadRequests.current.delete(sentence);
|
||||
})
|
||||
.catch(() => {
|
||||
// Prevent unhandled rejections from the cleanup-only chained promise.
|
||||
});
|
||||
}
|
||||
|
||||
return processPromise;
|
||||
|
|
@ -1024,31 +1044,33 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
||||
|
||||
const createHowl = async (retryCount = 0): Promise<Howl | null> => {
|
||||
try {
|
||||
// Get the processed audio data URI directly from processSentence
|
||||
const audioDataUri = await processSentence(sentence);
|
||||
if (!audioDataUri) {
|
||||
// Graceful exit for rate limit or skipped sentence
|
||||
console.log('Skipping playback for sentence (no audio generated)');
|
||||
return null;
|
||||
}
|
||||
let playErrorAttempts = 0;
|
||||
// Get the processed audio data URI directly from processSentence
|
||||
const audioDataUri = await processSentence(sentence);
|
||||
if (!audioDataUri) {
|
||||
// Graceful exit for rate limit / abort / intentionally skipped sentence
|
||||
console.log('Skipping playback for sentence (no audio generated)');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Force unload any previous Howl instance to free up resources
|
||||
if (activeHowl) {
|
||||
activeHowl.unload();
|
||||
}
|
||||
// Force unload any previous Howl instance to free up resources
|
||||
if (activeHowl) {
|
||||
activeHowl.unload();
|
||||
}
|
||||
|
||||
return new Howl({
|
||||
src: [audioDataUri],
|
||||
format: ['mp3', 'mpeg'],
|
||||
html5: true,
|
||||
preload: true,
|
||||
pool: 5,
|
||||
rate: audioSpeed,
|
||||
onload: function (this: Howl) {
|
||||
const estimate = pageTurnEstimateRef.current;
|
||||
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||||
if (!visualPageChangeHandlerRef.current) return;
|
||||
return new Howl({
|
||||
src: [audioDataUri],
|
||||
format: ['mp3', 'mpeg'],
|
||||
html5: true,
|
||||
preload: true,
|
||||
// We never need overlapping playback for a single sentence. Keeping this low avoids
|
||||
// Safari/HTML5 Audio pool exhaustion when retries happen.
|
||||
pool: 1,
|
||||
rate: audioSpeed,
|
||||
onload: function (this: Howl) {
|
||||
const estimate = pageTurnEstimateRef.current;
|
||||
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||||
if (!visualPageChangeHandlerRef.current) return;
|
||||
|
||||
const duration = this.duration();
|
||||
if (!duration || !Number.isFinite(duration)) return;
|
||||
|
|
@ -1066,46 +1088,69 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
|
||||
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
||||
}, delayMs);
|
||||
},
|
||||
onplay: () => {
|
||||
},
|
||||
onplay: () => {
|
||||
setIsProcessing(false);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
},
|
||||
onplayerror: function (this: Howl, error) {
|
||||
console.warn('Howl playback error:', error);
|
||||
playErrorAttempts += 1;
|
||||
|
||||
// Avoid looping for many seconds on Safari: if playback still fails after a single
|
||||
// recovery attempt, skip the sentence and pause.
|
||||
if (playErrorAttempts > 1) {
|
||||
setIsProcessing(false);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
},
|
||||
onplayerror: function (this: Howl, error) {
|
||||
console.warn('Howl playback error:', error);
|
||||
// Try to recover by forcing HTML5 audio mode
|
||||
if (this.state() === 'loaded') {
|
||||
setActiveHowl(null);
|
||||
this.unload();
|
||||
setIsPlaying(false);
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to recover by reloading once.
|
||||
if (this.state() === 'loaded') {
|
||||
this.unload();
|
||||
this.once('load', () => this.play());
|
||||
this.load();
|
||||
}
|
||||
},
|
||||
onloaderror: async function (this: Howl, error) {
|
||||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
// Calculate exponential backoff delay
|
||||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||
console.log(`Retrying in ${delay}ms...`);
|
||||
|
||||
// Free the current Howl/audio objects before retrying to avoid pool exhaustion.
|
||||
try {
|
||||
this.unload();
|
||||
this.once('load', () => {
|
||||
this.play();
|
||||
});
|
||||
this.load();
|
||||
} catch {
|
||||
// ignore unload errors
|
||||
}
|
||||
},
|
||||
onloaderror: async function (this: Howl, error) {
|
||||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
// Calculate exponential backoff delay
|
||||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||
console.log(`Retrying in ${delay}ms...`);
|
||||
// Wait for the delay
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// Wait for the delay
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// Try to create a new Howl instance
|
||||
// Try to create a new Howl instance
|
||||
try {
|
||||
const retryHowl = await createHowl(retryCount + 1);
|
||||
if (retryHowl) {
|
||||
setActiveHowl(retryHowl);
|
||||
retryHowl.play();
|
||||
} else {
|
||||
// No audio generated (quota/abort). Stop cleanly without spamming errors.
|
||||
setIsProcessing(false);
|
||||
setActiveHowl(null);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
} else {
|
||||
console.error('Max retries reached, moving to next sentence');
|
||||
} catch (err) {
|
||||
console.error('Error creating Howl instance:', err);
|
||||
setIsProcessing(false);
|
||||
setActiveHowl(null);
|
||||
this.unload();
|
||||
setIsPlaying(false);
|
||||
|
||||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||||
|
|
@ -1117,6 +1162,24 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
duration: 2000,
|
||||
});
|
||||
|
||||
advance();
|
||||
}
|
||||
} else {
|
||||
console.error('Max retries reached, moving to next sentence');
|
||||
setIsProcessing(false);
|
||||
setActiveHowl(null);
|
||||
this.unload();
|
||||
setIsPlaying(false);
|
||||
|
||||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||||
id: 'audio-load-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
advance();
|
||||
}
|
||||
},
|
||||
|
|
@ -1131,43 +1194,38 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
advance();
|
||||
}
|
||||
},
|
||||
onstop: function (this: Howl) {
|
||||
setIsProcessing(false);
|
||||
this.unload();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating Howl instance:', error);
|
||||
return null;
|
||||
}
|
||||
onstop: function (this: Howl) {
|
||||
setIsProcessing(false);
|
||||
this.unload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const howl = await createHowl();
|
||||
if (howl) {
|
||||
setActiveHowl(howl);
|
||||
return howl;
|
||||
if (!howl) {
|
||||
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
|
||||
// advancing or spamming errors.
|
||||
setActiveHowl(null);
|
||||
setIsProcessing(false);
|
||||
setIsPlaying(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error('Failed to create Howl instance');
|
||||
setActiveHowl(howl);
|
||||
return howl;
|
||||
} catch (error) {
|
||||
console.error('Error playing TTS:', error);
|
||||
setActiveHowl(null);
|
||||
setIsProcessing(false);
|
||||
|
||||
toast.error('Failed to process audio. Skipping problematic sentence.', {
|
||||
id: 'tts-processing-error',
|
||||
style: {
|
||||
background: 'var(--background)',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
// Skip the sentence but pause playback (user can resume manually).
|
||||
abortAudio(true);
|
||||
setIsPlaying(false);
|
||||
advance();
|
||||
return null;
|
||||
}
|
||||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||
}, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||
|
||||
const playAudio = useCallback(async () => {
|
||||
const sentence = sentences[currentIndex];
|
||||
|
|
@ -1245,6 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
* Preloads the next sentence's audio
|
||||
*/
|
||||
const preloadNextAudio = useCallback(async () => {
|
||||
if (isAtLimit) return;
|
||||
try {
|
||||
const nextSentence = sentences[currentIndex + 1];
|
||||
if (nextSentence) {
|
||||
|
|
@ -1283,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
} catch (error) {
|
||||
console.error('Error initiating preload:', error);
|
||||
}
|
||||
}, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
|
||||
}, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
|
||||
|
||||
/**
|
||||
* Main Playback Driver
|
||||
|
|
|
|||
|
|
@ -54,14 +54,14 @@ export const session = pgTable("session", {
|
|||
updatedAt: timestamp('updated_at').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id').notNull().references(() => user.id)
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
|
|
|
|||
|
|
@ -55,14 +55,14 @@ export const session = sqliteTable("session", {
|
|||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id').notNull().references(() => user.id)
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
|
||||
});
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ class RateLimitExceeded extends Error {
|
|||
name = 'RateLimitExceeded' as const;
|
||||
}
|
||||
|
||||
function getRowsAffected(result: unknown): number {
|
||||
if (typeof result !== 'object' || result === null) return 0;
|
||||
const rec = result as Record<string, unknown>;
|
||||
if (typeof rec.rowCount === 'number') return rec.rowCount;
|
||||
if (typeof rec.changes === 'number') return rec.changes;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
constructor() { }
|
||||
|
||||
|
|
@ -144,7 +152,6 @@ export class RateLimiter {
|
|||
|
||||
// Attempt to increment each bucket
|
||||
for (const bucket of buckets) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const updateResult = await tx.update(userTtsChars)
|
||||
.set({
|
||||
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
|
||||
|
|
@ -156,13 +163,10 @@ export class RateLimiter {
|
|||
lt(userTtsChars.charCount, bucket.limit)
|
||||
));
|
||||
|
||||
// Check if update actually happened
|
||||
const row = await tx.select({ count: userTtsChars.charCount }).from(userTtsChars)
|
||||
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!row || Number(row.count) > bucket.limit) {
|
||||
// If any bucket is already at/over its limit, reject the request (and roll back all increments).
|
||||
// Note: we intentionally allow a request to push a bucket over its limit; we only block when the
|
||||
// bucket was already exhausted before this request started.
|
||||
if (getRowsAffected(updateResult) <= 0) {
|
||||
throw new RateLimitExceeded();
|
||||
}
|
||||
}
|
||||
|
|
@ -180,10 +184,6 @@ export class RateLimiter {
|
|||
|
||||
const effective = pickEffectiveResult(bucketResults);
|
||||
|
||||
if (effective.currentCount > effective.limit) {
|
||||
throw new RateLimitExceeded();
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
currentCount: effective.currentCount,
|
||||
|
|
@ -330,4 +330,4 @@ export class RateLimiter {
|
|||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const rateLimiter = new RateLimiter();
|
||||
export const rateLimiter = new RateLimiter();
|
||||
|
|
|
|||
Loading…
Reference in a new issue