From b2bbd8fbef20269cf2b262fd7e63ea7f575b928b Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sun, 14 Jun 2026 18:13:39 -0600
Subject: [PATCH] feat(data-storage): migrate all user state from IndexedDB to
server-backed storage
Remove all Dexie/IndexedDB code and dependencies, including document and preview caches, local config, onboarding, and migration logic. Replace with server-backed React Query hooks for documents, folders, preferences, onboarding, and progress. Add browser Cache Storage for blob caching of documents, previews, and audio. Update API routes, database schema, and tests to support folder management, onboarding state, and server-side persistence of all user data. Refactor UI and hooks to use server state exclusively, ensuring all user state is synced and portable across devices.
BREAKING CHANGE: All user data, preferences, onboarding, and document state are now stored and synced on the server; browser IndexedDB is no longer used. Existing local-only data will not be available after this update.
---
docs-site/docs/configure/admin-panel.md | 2 +-
docs-site/docs/reference/stack.md | 2 +-
eslint.config.mjs | 1 -
package.json | 4 +-
.../tests/fixtures/fake-control-plane.ts | 4 +-
.../tests/unit/orphan-recovery.test.ts | 8 +-
.../postgres/0012_user_additions.sql | 26 +
.../postgres/meta/0012_snapshot.json | 2263 +++++++++++++++++
.../migrations/postgres/meta/_journal.json | 7 +
.../migrations/sqlite/0012_user_additions.sql | 46 +
.../migrations/sqlite/meta/0012_snapshot.json | 2084 +++++++++++++++
.../migrations/sqlite/meta/_journal.json | 7 +
packages/database/src/schema.ts | 2 +
packages/database/src/schema_postgres.ts | 28 +
packages/database/src/schema_sqlite.ts | 28 +
pnpm-lock.yaml | 22 -
src/app/(app)/epub/[id]/page.tsx | 16 +-
src/app/(app)/epub/[id]/useEpubDocument.ts | 6 +-
src/app/(app)/html/[id]/page.tsx | 16 +-
src/app/(app)/html/[id]/useHtmlDocument.ts | 10 +-
src/app/(app)/layout.tsx | 7 +-
src/app/(app)/pdf/[id]/page.tsx | 23 +-
src/app/(app)/pdf/[id]/usePdfDocument.ts | 68 +-
src/app/api/audiobook/chapter/route.ts | 3 -
src/app/api/documents/[id]/opened/route.ts | 27 +
.../documents/blob/preview/ensure/route.ts | 1 +
src/app/api/documents/folders/route.ts | 43 +
src/app/api/documents/route.ts | 5 +
src/app/api/folders/[id]/route.ts | 52 +
src/app/api/folders/route.ts | 81 +
src/app/api/tts/segments/ensure/route.ts | 3 -
src/app/api/tts/voices/route.ts | 3 -
src/app/api/user/claim/route.ts | 10 +-
src/app/api/user/export/route.ts | 8 +
.../state/changelog/version-check/route.ts | 41 +-
src/app/api/user/state/onboarding/route.ts | 78 +
src/app/api/user/state/preferences/route.ts | 37 +-
src/app/providers.tsx | 11 +-
src/components/PrivacyModal.tsx | 5 +-
src/components/SettingsModal.tsx | 108 +-
src/components/auth/ClaimDataModal.tsx | 6 +
src/components/doclist/DocumentList.tsx | 161 +-
src/components/doclist/DocumentPreview.tsx | 2 +-
.../documents/DexieMigrationModal.tsx | 186 --
src/contexts/ConfigContext.tsx | 494 +---
src/contexts/DocumentContext.tsx | 38 +-
src/contexts/OnboardingFlowContext.tsx | 133 +-
src/contexts/RuntimeConfigContext.tsx | 2 +-
src/contexts/TTSContext.tsx | 82 +-
src/hooks/audio/useVoiceManagement.ts | 8 +-
src/hooks/epub/useEPUBAudiobook.ts | 12 +-
src/hooks/epub/useEPUBDocuments.ts | 53 -
src/hooks/epub/useEPUBLocationController.ts | 42 +-
src/hooks/html/useHTMLDocuments.ts | 52 -
src/hooks/pdf/usePDFDocuments.ts | 50 -
src/hooks/useDocumentLanguage.ts | 41 +-
src/hooks/useDocumentProgress.ts | 54 +
src/hooks/useDocumentSettings.ts | 32 +
src/hooks/useFolders.ts | 103 +
src/hooks/useOnboardingState.ts | 47 +
src/hooks/useUserPreferences.ts | 29 +
src/lib/client/api/documents.ts | 20 +-
src/lib/client/api/user-state.ts | 149 --
src/lib/client/audiobooks/pipeline.ts | 16 +-
src/lib/client/cache/audio.ts | 28 +
src/lib/client/cache/blob-cache.ts | 60 +
src/lib/client/cache/documents.ts | 176 +-
src/lib/client/cache/previews.ts | 144 +-
src/lib/client/dexie.ts | 1285 ----------
src/lib/client/epub/location-controller.ts | 22 +
src/lib/client/onboarding-flow.ts | 7 +-
src/lib/client/query-keys.ts | 15 +
src/lib/server/admin/resolve-credentials.ts | 56 +-
src/lib/server/user/claim-data.ts | 30 +-
src/lib/server/user/data-export.ts | 6 +
src/lib/server/user/preferences-normalize.ts | 5 +
src/lib/server/user/preferences-payload.ts | 52 -
src/lib/shared/onboarding-state.ts | 45 -
src/types/config.ts | 10 -
src/types/documents.ts | 1 +
src/types/user-state.ts | 1 +
tests/folders.spec.ts | 2 +-
tests/helpers.ts | 23 +-
tests/unit/blob-cache.vitest.spec.ts | 74 +
tests/unit/document-cache.vitest.spec.ts | 114 -
.../epub-location-controller.vitest.spec.ts | 2 +-
tests/unit/onboarding-flow.vitest.spec.ts | 18 -
tests/unit/onboarding-state.vitest.spec.ts | 19 -
tests/unit/query-keys.vitest.spec.ts | 19 +
.../transfer-user-documents.vitest.spec.ts | 4 +
.../unit/tts-provider-catalog.vitest.spec.ts | 1 -
.../unit/user-preferences-sync.vitest.spec.ts | 81 -
tests/upload.spec.ts | 51 +-
93 files changed, 5820 insertions(+), 3539 deletions(-)
create mode 100644 packages/database/migrations/postgres/0012_user_additions.sql
create mode 100644 packages/database/migrations/postgres/meta/0012_snapshot.json
create mode 100644 packages/database/migrations/sqlite/0012_user_additions.sql
create mode 100644 packages/database/migrations/sqlite/meta/0012_snapshot.json
create mode 100644 src/app/api/documents/[id]/opened/route.ts
create mode 100644 src/app/api/documents/folders/route.ts
create mode 100644 src/app/api/folders/[id]/route.ts
create mode 100644 src/app/api/folders/route.ts
create mode 100644 src/app/api/user/state/onboarding/route.ts
delete mode 100644 src/components/documents/DexieMigrationModal.tsx
delete mode 100644 src/hooks/epub/useEPUBDocuments.ts
delete mode 100644 src/hooks/html/useHTMLDocuments.ts
delete mode 100644 src/hooks/pdf/usePDFDocuments.ts
create mode 100644 src/hooks/useDocumentProgress.ts
create mode 100644 src/hooks/useDocumentSettings.ts
create mode 100644 src/hooks/useFolders.ts
create mode 100644 src/hooks/useOnboardingState.ts
create mode 100644 src/hooks/useUserPreferences.ts
create mode 100644 src/lib/client/cache/audio.ts
create mode 100644 src/lib/client/cache/blob-cache.ts
delete mode 100644 src/lib/client/dexie.ts
create mode 100644 src/lib/client/epub/location-controller.ts
create mode 100644 src/lib/client/query-keys.ts
delete mode 100644 src/lib/server/user/preferences-payload.ts
delete mode 100644 src/lib/shared/onboarding-state.ts
create mode 100644 tests/unit/blob-cache.vitest.spec.ts
delete mode 100644 tests/unit/document-cache.vitest.spec.ts
delete mode 100644 tests/unit/onboarding-state.vitest.spec.ts
create mode 100644 tests/unit/query-keys.vitest.spec.ts
delete mode 100644 tests/unit/user-preferences-sync.vitest.spec.ts
diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md
index f239d64..aa78797 100644
--- a/docs-site/docs/configure/admin-panel.md
+++ b/docs-site/docs/configure/admin-panel.md
@@ -41,7 +41,7 @@ When a non-admin user picks a shared provider in **Settings → TTS Provider**:
- The API key / base URL fields are hidden — those credentials never leave the server.
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
-- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
+- TTS credentials are resolved only from admin-managed shared providers and are never accepted from client request headers.
Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:
diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md
index f260e31..da6a7db 100644
--- a/docs-site/docs/reference/stack.md
+++ b/docs-site/docs/reference/stack.md
@@ -20,7 +20,7 @@ title: Stack
- Interactions: `react-dnd`, `react-dropzone`
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
-- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
+- Browser blob performance cache: Cache Storage (evictable; never authoritative)
- Audio playback: [Howler.js](https://howlerjs.com/)
- Notifications: `react-hot-toast`
- Document rendering:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index e7af21a..40e6794 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -29,7 +29,6 @@ const UI_ARCHITECTURE_FILES = [
"src/components/doclist/**/*.{ts,tsx}",
"src/components/documents/DocumentUploader.tsx",
"src/components/documents/DocumentSelectionModal.tsx",
- "src/components/documents/DexieMigrationModal.tsx",
"src/components/documents/ZoomControl.tsx",
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",
diff --git a/package.json b/package.json
index 2716aac..b084187 100644
--- a/package.json
+++ b/package.json
@@ -54,8 +54,6 @@
"cmpstr": "^3.3.0",
"compromise": "^14.15.1",
"core-js": "^3.49.0",
- "dexie": "^4.4.3",
- "dexie-react-hooks": "^4.4.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"epubjs": "^0.3.93",
@@ -102,8 +100,8 @@
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.19",
- "postcss": "^8.5.15",
"openapi-typescript": "^7.10.1",
+ "postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vitest": "^4.1.8"
diff --git a/packages/compute-worker/tests/fixtures/fake-control-plane.ts b/packages/compute-worker/tests/fixtures/fake-control-plane.ts
index 0cd1c86..1dc1b50 100644
--- a/packages/compute-worker/tests/fixtures/fake-control-plane.ts
+++ b/packages/compute-worker/tests/fixtures/fake-control-plane.ts
@@ -19,7 +19,7 @@ export class FakeControlPlane {
private readonly artifactKeys = new Set();
private nextOpId = 1;
- readonly deps: ComputeWorkerRouteDeps = {
+ readonly deps = {
orchestrator: {
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
markRunning: async (input) => this.updateState(input.opId, {
@@ -75,7 +75,7 @@ export class FakeControlPlane {
},
},
artifactExists: async (key) => this.artifactKeys.has(key),
- };
+ } satisfies ComputeWorkerRouteDeps;
seedState(state: ComputeState): void {
this.stateByOpId.set(state.opId, state);
diff --git a/packages/compute-worker/tests/unit/orphan-recovery.test.ts b/packages/compute-worker/tests/unit/orphan-recovery.test.ts
index 54db314..7ec771d 100644
--- a/packages/compute-worker/tests/unit/orphan-recovery.test.ts
+++ b/packages/compute-worker/tests/unit/orphan-recovery.test.ts
@@ -24,8 +24,8 @@ describe('orphan recovery', () => {
});
const firstSweep = await recoverOrphanedOperations({
- operationStateStore: fake.deps.operationStateStore!,
- orchestrator: fake.deps.orchestrator!,
+ operationStateStore: fake.deps.operationStateStore,
+ orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000,
@@ -38,8 +38,8 @@ describe('orphan recovery', () => {
vi.advanceTimersByTime(31_000);
const secondSweep = await recoverOrphanedOperations({
- operationStateStore: fake.deps.operationStateStore!,
- orchestrator: fake.deps.orchestrator!,
+ operationStateStore: fake.deps.operationStateStore,
+ orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000,
diff --git a/packages/database/migrations/postgres/0012_user_additions.sql b/packages/database/migrations/postgres/0012_user_additions.sql
new file mode 100644
index 0000000..ec34226
--- /dev/null
+++ b/packages/database/migrations/postgres/0012_user_additions.sql
@@ -0,0 +1,26 @@
+CREATE TABLE "user_folders" (
+ "id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "name" text NOT NULL,
+ "position" integer DEFAULT 0 NOT NULL,
+ "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
+ "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
+ CONSTRAINT "user_folders_id_user_id_pk" PRIMARY KEY("id","user_id")
+);
+--> statement-breakpoint
+CREATE TABLE "user_onboarding" (
+ "user_id" text PRIMARY KEY NOT NULL,
+ "privacy_accepted_at_ms" bigint,
+ "last_seen_app_version" text,
+ "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
+ "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
+);
+--> statement-breakpoint
+ALTER TABLE "documents" ADD COLUMN "folder_id" text;--> statement-breakpoint
+ALTER TABLE "documents" ADD COLUMN "recently_opened_at" bigint;--> statement-breakpoint
+ALTER TABLE "user_folders" ADD CONSTRAINT "user_folders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_user_folders_user_position" ON "user_folders" USING btree ("user_id","position");--> statement-breakpoint
+ALTER TABLE "documents" ADD CONSTRAINT "documents_folder_id_user_id_user_folders_id_user_id_fk" FOREIGN KEY ("folder_id","user_id") REFERENCES "public"."user_folders"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_documents_user_id_folder" ON "documents" USING btree ("user_id","folder_id");--> statement-breakpoint
+CREATE INDEX "idx_documents_user_id_recently_opened" ON "documents" USING btree ("user_id","recently_opened_at");
\ No newline at end of file
diff --git a/packages/database/migrations/postgres/meta/0012_snapshot.json b/packages/database/migrations/postgres/meta/0012_snapshot.json
new file mode 100644
index 0000000..93bbd9c
--- /dev/null
+++ b/packages/database/migrations/postgres/meta/0012_snapshot.json
@@ -0,0 +1,2263 @@
+{
+ "id": "a79acd69-9095-4bf3-87e7-6acda32c7283",
+ "prevId": "6fa9be62-3d2b-43d2-8c49-11d9c6411a33",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.admin_providers": {
+ "name": "admin_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_ciphertext": {
+ "name": "api_key_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key_iv": {
+ "name": "api_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key_last4": {
+ "name": "api_key_last4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_model": {
+ "name": "default_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_instructions": {
+ "name": "default_instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "admin_providers_slug_unique": {
+ "name": "admin_providers_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.admin_settings": {
+ "name": "admin_settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value_json": {
+ "name": "value_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'admin'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audiobook_chapters": {
+ "name": "audiobook_chapters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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_user_id_user_id_fk": {
+ "name": "audiobook_chapters_user_id_user_id_fk",
+ "tableFrom": "audiobook_chapters",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
+ "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
+ "tableFrom": "audiobook_chapters",
+ "tableTo": "audiobooks",
+ "columnsFrom": [
+ "book_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "id",
+ "user_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "audiobook_chapters_id_user_id_pk": {
+ "name": "audiobook_chapters_id_user_id_pk",
+ "columns": [
+ "id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audiobooks": {
+ "name": "audiobooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audiobooks_user_id_user_id_fk": {
+ "name": "audiobooks_user_id_user_id_fk",
+ "tableFrom": "audiobooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "audiobooks_id_user_id_pk": {
+ "name": "audiobooks_id_user_id_pk",
+ "columns": [
+ "id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_blob_leases": {
+ "name": "document_blob_leases",
+ "schema": "",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lease_until_ms": {
+ "name": "lease_until_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_previews": {
+ "name": "document_previews",
+ "schema": "",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "variant": {
+ "name": "variant",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "source_last_modified_ms": {
+ "name": "source_last_modified_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "object_key": {
+ "name": "object_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'image/jpeg'"
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until_ms": {
+ "name": "lease_until_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_at_ms": {
+ "name": "updated_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_document_previews_status_lease": {
+ "name": "idx_document_previews_status_lease",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lease_until_ms",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "document_previews_document_id_namespace_variant_pk": {
+ "name": "document_previews_document_id_namespace_variant_pk",
+ "columns": [
+ "document_id",
+ "namespace",
+ "variant"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_settings": {
+ "name": "document_settings",
+ "schema": "",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_json": {
+ "name": "data_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_document_settings_user_id": {
+ "name": "idx_document_settings_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_settings_user_id_user_id_fk": {
+ "name": "document_settings_user_id_user_id_fk",
+ "tableFrom": "document_settings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "document_settings_document_id_user_id_pk": {
+ "name": "document_settings_document_id_user_id_pk",
+ "columns": [
+ "document_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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
+ },
+ "folder_id": {
+ "name": "folder_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recently_opened_at": {
+ "name": "recently_opened_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_documents_user_id": {
+ "name": "idx_documents_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_documents_user_id_last_modified": {
+ "name": "idx_documents_user_id_last_modified",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_modified",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_documents_user_id_folder": {
+ "name": "idx_documents_user_id_folder",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_documents_user_id_recently_opened": {
+ "name": "idx_documents_user_id_recently_opened",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "recently_opened_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_user_id_user_id_fk": {
+ "name": "documents_user_id_user_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "documents_folder_id_user_id_user_folders_id_user_id_fk": {
+ "name": "documents_folder_id_user_id_user_folders_id_user_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "user_folders",
+ "columnsFrom": [
+ "folder_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "id",
+ "user_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "documents_id_user_id_pk": {
+ "name": "documents_id_user_id_pk",
+ "columns": [
+ "id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scheduled_tasks": {
+ "name": "scheduled_tasks",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "interval_ms": {
+ "name": "interval_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_duration_ms": {
+ "name": "last_duration_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_result_json": {
+ "name": "last_result_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_requested": {
+ "name": "run_requested",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "running_since": {
+ "name": "running_since",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "scheduled_tasks_interval_ms_positive": {
+ "name": "scheduled_tasks_interval_ms_positive",
+ "value": "\"scheduled_tasks\".\"interval_ms\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.tts_segment_entries": {
+ "name": "tts_segment_entries",
+ "schema": "",
+ "columns": {
+ "segment_entry_id": {
+ "name": "segment_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reader_type": {
+ "name": "reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_version": {
+ "name": "document_version",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_key": {
+ "name": "segment_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locator_reader_rank": {
+ "name": "locator_reader_rank",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_reader_type": {
+ "name": "locator_reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_page": {
+ "name": "locator_page",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_spine_index": {
+ "name": "locator_spine_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_spine_href": {
+ "name": "locator_spine_href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_char_offset": {
+ "name": "locator_char_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_location": {
+ "name": "locator_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locator_identity_key": {
+ "name": "locator_identity_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text_hash": {
+ "name": "text_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text_length": {
+ "name": "text_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_tts_segment_entries_manifest_sort": {
+ "name": "idx_tts_segment_entries_manifest_sort",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_reader_rank",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_spine_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_char_offset",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_spine_href",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_page",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_location",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "segment_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_identity_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_tts_segment_entries_manifest_group": {
+ "name": "idx_tts_segment_entries_manifest_group",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "segment_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "locator_identity_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_tts_segment_entries_scope": {
+ "name": "idx_tts_segment_entries_scope",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tts_segment_entries_user_id_user_id_fk": {
+ "name": "tts_segment_entries_user_id_user_id_fk",
+ "tableFrom": "tts_segment_entries",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tts_segment_entries_segment_entry_id_user_id_pk": {
+ "name": "tts_segment_entries_segment_entry_id_user_id_pk",
+ "columns": [
+ "segment_entry_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tts_segment_variants": {
+ "name": "tts_segment_variants",
+ "schema": "",
+ "columns": {
+ "segment_id": {
+ "name": "segment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_entry_id": {
+ "name": "segment_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "settings_hash": {
+ "name": "settings_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "settings_json": {
+ "name": "settings_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "audio_key": {
+ "name": "audio_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "audio_format": {
+ "name": "audio_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'mp3'"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "alignment_json": {
+ "name": "alignment_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_tts_segment_variants_entry": {
+ "name": "idx_tts_segment_variants_entry",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "segment_entry_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_tts_segment_variants_status": {
+ "name": "idx_tts_segment_variants_status",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_tts_segment_variants_unique_settings": {
+ "name": "idx_tts_segment_variants_unique_settings",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "segment_entry_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "settings_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tts_segment_variants_user_id_user_id_fk": {
+ "name": "tts_segment_variants_user_id_user_id_fk",
+ "tableFrom": "tts_segment_variants",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": {
+ "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk",
+ "tableFrom": "tts_segment_variants",
+ "tableTo": "tts_segment_entries",
+ "columnsFrom": [
+ "segment_entry_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "segment_entry_id",
+ "user_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tts_segment_variants_segment_id_user_id_pk": {
+ "name": "tts_segment_variants_segment_id_user_id_pk",
+ "columns": [
+ "segment_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_document_progress": {
+ "name": "user_document_progress",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reader_type": {
+ "name": "reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "progress": {
+ "name": "progress",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_user_document_progress_user_id_updated_at": {
+ "name": "idx_user_document_progress_user_id_updated_at",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_document_progress_user_id_user_id_fk": {
+ "name": "user_document_progress_user_id_user_id_fk",
+ "tableFrom": "user_document_progress",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_document_progress_user_id_document_id_pk": {
+ "name": "user_document_progress_user_id_document_id_pk",
+ "columns": [
+ "user_id",
+ "document_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_folders": {
+ "name": "user_folders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_user_folders_user_position": {
+ "name": "idx_user_folders_user_position",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_folders_user_id_user_id_fk": {
+ "name": "user_folders_user_id_user_id_fk",
+ "tableFrom": "user_folders",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_folders_id_user_id_pk": {
+ "name": "user_folders_id_user_id_pk",
+ "columns": [
+ "id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_job_events": {
+ "name": "user_job_events",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "op_id": {
+ "name": "op_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "idx_user_job_events_user_action_created": {
+ "name": "idx_user_job_events_user_action_created",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_job_events_user_id_user_id_fk": {
+ "name": "user_job_events_user_id_user_id_fk",
+ "tableFrom": "user_job_events",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_job_events_user_id_action_op_id_pk": {
+ "name": "user_job_events_user_id_action_op_id_pk",
+ "columns": [
+ "user_id",
+ "action",
+ "op_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_onboarding": {
+ "name": "user_onboarding",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "privacy_accepted_at_ms": {
+ "name": "privacy_accepted_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen_app_version": {
+ "name": "last_seen_app_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_onboarding_user_id_user_id_fk": {
+ "name": "user_onboarding_user_id_user_id_fk",
+ "tableFrom": "user_onboarding",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_preferences": {
+ "name": "user_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "data_json": {
+ "name": "data_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_preferences_user_id_user_id_fk": {
+ "name": "user_preferences_user_id_user_id_fk",
+ "tableFrom": "user_preferences",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(extract(epoch from now()) * 1000)::bigint"
+ }
+ },
+ "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.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,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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.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,
+ "default": "now()"
+ },
+ "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": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "is_anonymous": {
+ "name": "is_anonymous",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "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
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/migrations/postgres/meta/_journal.json b/packages/database/migrations/postgres/meta/_journal.json
index cfe7d84..dd955c7 100644
--- a/packages/database/migrations/postgres/meta/_journal.json
+++ b/packages/database/migrations/postgres/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1780851107819,
"tag": "0011_scheduled-tasks",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1781466220153,
+ "tag": "0012_user_additions",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/migrations/sqlite/0012_user_additions.sql b/packages/database/migrations/sqlite/0012_user_additions.sql
new file mode 100644
index 0000000..a93508b
--- /dev/null
+++ b/packages/database/migrations/sqlite/0012_user_additions.sql
@@ -0,0 +1,46 @@
+CREATE TABLE `user_folders` (
+ `id` text NOT NULL,
+ `user_id` text NOT NULL,
+ `name` text NOT NULL,
+ `position` integer DEFAULT 0 NOT NULL,
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
+ PRIMARY KEY(`id`, `user_id`),
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `idx_user_folders_user_position` ON `user_folders` (`user_id`,`position`);--> statement-breakpoint
+CREATE TABLE `user_onboarding` (
+ `user_id` text PRIMARY KEY NOT NULL,
+ `privacy_accepted_at_ms` integer,
+ `last_seen_app_version` text,
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_documents` (
+ `id` text NOT NULL,
+ `user_id` text NOT NULL,
+ `name` text NOT NULL,
+ `type` text NOT NULL,
+ `size` integer NOT NULL,
+ `last_modified` integer NOT NULL,
+ `file_path` text NOT NULL,
+ `folder_id` text,
+ `recently_opened_at` integer,
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
+ PRIMARY KEY(`id`, `user_id`),
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`folder_id`,`user_id`) REFERENCES `user_folders`(`id`,`user_id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+INSERT INTO `__new_documents`("id", "user_id", "name", "type", "size", "last_modified", "file_path", "folder_id", "recently_opened_at", "created_at") SELECT "id", "user_id", "name", "type", "size", "last_modified", "file_path", NULL, NULL, "created_at" FROM `documents`;--> statement-breakpoint
+DROP TABLE `documents`;--> statement-breakpoint
+ALTER TABLE `__new_documents` RENAME TO `documents`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint
+CREATE INDEX `idx_documents_user_id_last_modified` ON `documents` (`user_id`,`last_modified`);--> statement-breakpoint
+CREATE INDEX `idx_documents_user_id_folder` ON `documents` (`user_id`,`folder_id`);--> statement-breakpoint
+CREATE INDEX `idx_documents_user_id_recently_opened` ON `documents` (`user_id`,`recently_opened_at`);
\ No newline at end of file
diff --git a/packages/database/migrations/sqlite/meta/0012_snapshot.json b/packages/database/migrations/sqlite/meta/0012_snapshot.json
new file mode 100644
index 0000000..ca1ad6d
--- /dev/null
+++ b/packages/database/migrations/sqlite/meta/0012_snapshot.json
@@ -0,0 +1,2084 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "d679bf2e-911b-44ea-b7d5-82b6404e6a4e",
+ "prevId": "d063930a-ae25-4ca0-8b38-7092a16e3503",
+ "tables": {
+ "admin_providers": {
+ "name": "admin_providers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key_ciphertext": {
+ "name": "api_key_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key_iv": {
+ "name": "api_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key_last4": {
+ "name": "api_key_last4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_model": {
+ "name": "default_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_instructions": {
+ "name": "default_instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "admin_providers_slug_unique": {
+ "name": "admin_providers_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "admin_settings": {
+ "name": "admin_settings",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value_json": {
+ "name": "value_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'admin'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audiobook_chapters": {
+ "name": "audiobook_chapters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chapter_index": {
+ "name": "chapter_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audiobook_chapters_user_id_user_id_fk": {
+ "name": "audiobook_chapters_user_id_user_id_fk",
+ "tableFrom": "audiobook_chapters",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
+ "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
+ "tableFrom": "audiobook_chapters",
+ "tableTo": "audiobooks",
+ "columnsFrom": [
+ "book_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "id",
+ "user_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "audiobook_chapters_id_user_id_pk": {
+ "columns": [
+ "id",
+ "user_id"
+ ],
+ "name": "audiobook_chapters_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audiobooks": {
+ "name": "audiobooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_path": {
+ "name": "cover_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audiobooks_user_id_user_id_fk": {
+ "name": "audiobooks_user_id_user_id_fk",
+ "tableFrom": "audiobooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "audiobooks_id_user_id_pk": {
+ "columns": [
+ "id",
+ "user_id"
+ ],
+ "name": "audiobooks_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_blob_leases": {
+ "name": "document_blob_leases",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_until_ms": {
+ "name": "lease_until_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_previews": {
+ "name": "document_previews",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "variant": {
+ "name": "variant",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'queued'"
+ },
+ "source_last_modified_ms": {
+ "name": "source_last_modified_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "object_key": {
+ "name": "object_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'image/jpeg'"
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lease_until_ms": {
+ "name": "lease_until_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at_ms": {
+ "name": "updated_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_document_previews_status_lease": {
+ "name": "idx_document_previews_status_lease",
+ "columns": [
+ "status",
+ "lease_until_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "document_previews_document_id_namespace_variant_pk": {
+ "columns": [
+ "document_id",
+ "namespace",
+ "variant"
+ ],
+ "name": "document_previews_document_id_namespace_variant_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_settings": {
+ "name": "document_settings",
+ "columns": {
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data_json": {
+ "name": "data_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_document_settings_user_id": {
+ "name": "idx_document_settings_user_id",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "document_settings_user_id_user_id_fk": {
+ "name": "document_settings_user_id_user_id_fk",
+ "tableFrom": "document_settings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "document_settings_document_id_user_id_pk": {
+ "columns": [
+ "document_id",
+ "user_id"
+ ],
+ "name": "document_settings_document_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "documents": {
+ "name": "documents",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folder_id": {
+ "name": "folder_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recently_opened_at": {
+ "name": "recently_opened_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_documents_user_id": {
+ "name": "idx_documents_user_id",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_documents_user_id_last_modified": {
+ "name": "idx_documents_user_id_last_modified",
+ "columns": [
+ "user_id",
+ "last_modified"
+ ],
+ "isUnique": false
+ },
+ "idx_documents_user_id_folder": {
+ "name": "idx_documents_user_id_folder",
+ "columns": [
+ "user_id",
+ "folder_id"
+ ],
+ "isUnique": false
+ },
+ "idx_documents_user_id_recently_opened": {
+ "name": "idx_documents_user_id_recently_opened",
+ "columns": [
+ "user_id",
+ "recently_opened_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "documents_user_id_user_id_fk": {
+ "name": "documents_user_id_user_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "documents_folder_id_user_id_user_folders_id_user_id_fk": {
+ "name": "documents_folder_id_user_id_user_folders_id_user_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "user_folders",
+ "columnsFrom": [
+ "folder_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "id",
+ "user_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "documents_id_user_id_pk": {
+ "columns": [
+ "id",
+ "user_id"
+ ],
+ "name": "documents_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_tasks": {
+ "name": "scheduled_tasks",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "interval_ms": {
+ "name": "interval_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'idle'"
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_duration_ms": {
+ "name": "last_duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_result_json": {
+ "name": "last_result_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "run_requested": {
+ "name": "run_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "running_since": {
+ "name": "running_since",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "scheduled_tasks_interval_ms_positive": {
+ "name": "scheduled_tasks_interval_ms_positive",
+ "value": "\"scheduled_tasks\".\"interval_ms\" > 0"
+ }
+ }
+ },
+ "tts_segment_entries": {
+ "name": "tts_segment_entries",
+ "columns": {
+ "segment_entry_id": {
+ "name": "segment_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reader_type": {
+ "name": "reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_version": {
+ "name": "document_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "segment_key": {
+ "name": "segment_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locator_reader_rank": {
+ "name": "locator_reader_rank",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_reader_type": {
+ "name": "locator_reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_page": {
+ "name": "locator_page",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_spine_index": {
+ "name": "locator_spine_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_spine_href": {
+ "name": "locator_spine_href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_char_offset": {
+ "name": "locator_char_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_location": {
+ "name": "locator_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locator_identity_key": {
+ "name": "locator_identity_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text_hash": {
+ "name": "text_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text_length": {
+ "name": "text_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_tts_segment_entries_manifest_sort": {
+ "name": "idx_tts_segment_entries_manifest_sort",
+ "columns": [
+ "user_id",
+ "document_id",
+ "document_version",
+ "locator_reader_rank",
+ "locator_spine_index",
+ "locator_char_offset",
+ "locator_spine_href",
+ "locator_page",
+ "locator_location",
+ "segment_index",
+ "locator_identity_key"
+ ],
+ "isUnique": false
+ },
+ "idx_tts_segment_entries_manifest_group": {
+ "name": "idx_tts_segment_entries_manifest_group",
+ "columns": [
+ "user_id",
+ "document_id",
+ "document_version",
+ "segment_index",
+ "locator_identity_key"
+ ],
+ "isUnique": false
+ },
+ "idx_tts_segment_entries_scope": {
+ "name": "idx_tts_segment_entries_scope",
+ "columns": [
+ "user_id",
+ "document_id",
+ "document_version"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tts_segment_entries_user_id_user_id_fk": {
+ "name": "tts_segment_entries_user_id_user_id_fk",
+ "tableFrom": "tts_segment_entries",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tts_segment_entries_segment_entry_id_user_id_pk": {
+ "columns": [
+ "segment_entry_id",
+ "user_id"
+ ],
+ "name": "tts_segment_entries_segment_entry_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tts_segment_variants": {
+ "name": "tts_segment_variants",
+ "columns": {
+ "segment_id": {
+ "name": "segment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "segment_entry_id": {
+ "name": "segment_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "settings_hash": {
+ "name": "settings_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "settings_json": {
+ "name": "settings_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audio_key": {
+ "name": "audio_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audio_format": {
+ "name": "audio_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'mp3'"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "alignment_json": {
+ "name": "alignment_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_tts_segment_variants_entry": {
+ "name": "idx_tts_segment_variants_entry",
+ "columns": [
+ "user_id",
+ "segment_entry_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "idx_tts_segment_variants_status": {
+ "name": "idx_tts_segment_variants_status",
+ "columns": [
+ "user_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_tts_segment_variants_unique_settings": {
+ "name": "idx_tts_segment_variants_unique_settings",
+ "columns": [
+ "user_id",
+ "segment_entry_id",
+ "settings_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tts_segment_variants_user_id_user_id_fk": {
+ "name": "tts_segment_variants_user_id_user_id_fk",
+ "tableFrom": "tts_segment_variants",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": {
+ "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk",
+ "tableFrom": "tts_segment_variants",
+ "tableTo": "tts_segment_entries",
+ "columnsFrom": [
+ "segment_entry_id",
+ "user_id"
+ ],
+ "columnsTo": [
+ "segment_entry_id",
+ "user_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tts_segment_variants_segment_id_user_id_pk": {
+ "columns": [
+ "segment_id",
+ "user_id"
+ ],
+ "name": "tts_segment_variants_segment_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_document_progress": {
+ "name": "user_document_progress",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reader_type": {
+ "name": "reader_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "progress": {
+ "name": "progress",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_user_document_progress_user_id_updated_at": {
+ "name": "idx_user_document_progress_user_id_updated_at",
+ "columns": [
+ "user_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "user_document_progress_user_id_user_id_fk": {
+ "name": "user_document_progress_user_id_user_id_fk",
+ "tableFrom": "user_document_progress",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_document_progress_user_id_document_id_pk": {
+ "columns": [
+ "user_id",
+ "document_id"
+ ],
+ "name": "user_document_progress_user_id_document_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_folders": {
+ "name": "user_folders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_user_folders_user_position": {
+ "name": "idx_user_folders_user_position",
+ "columns": [
+ "user_id",
+ "position"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "user_folders_user_id_user_id_fk": {
+ "name": "user_folders_user_id_user_id_fk",
+ "tableFrom": "user_folders",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_folders_id_user_id_pk": {
+ "columns": [
+ "id",
+ "user_id"
+ ],
+ "name": "user_folders_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_job_events": {
+ "name": "user_job_events",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "op_id": {
+ "name": "op_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_user_job_events_user_action_created": {
+ "name": "idx_user_job_events_user_action_created",
+ "columns": [
+ "user_id",
+ "action",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "user_job_events_user_id_user_id_fk": {
+ "name": "user_job_events_user_id_user_id_fk",
+ "tableFrom": "user_job_events",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_job_events_user_id_action_op_id_pk": {
+ "columns": [
+ "user_id",
+ "action",
+ "op_id"
+ ],
+ "name": "user_job_events_user_id_action_op_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_onboarding": {
+ "name": "user_onboarding",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "privacy_accepted_at_ms": {
+ "name": "privacy_accepted_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_seen_app_version": {
+ "name": "last_seen_app_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_onboarding_user_id_user_id_fk": {
+ "name": "user_onboarding_user_id_user_id_fk",
+ "tableFrom": "user_onboarding",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_preferences": {
+ "name": "user_preferences",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data_json": {
+ "name": "data_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "client_updated_at_ms": {
+ "name": "client_updated_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_preferences_user_id_user_id_fk": {
+ "name": "user_preferences_user_id_user_id_fk",
+ "tableFrom": "user_preferences",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_tts_chars": {
+ "name": "user_tts_chars",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "char_count": {
+ "name": "char_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "idx_user_tts_chars_date": {
+ "name": "idx_user_tts_chars_date",
+ "columns": [
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "user_tts_chars_user_id_date_pk": {
+ "columns": [
+ "user_id",
+ "date"
+ ],
+ "name": "user_tts_chars_user_id_date_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "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": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "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": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "is_anonymous": {
+ "name": "is_anonymous",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/migrations/sqlite/meta/_journal.json b/packages/database/migrations/sqlite/meta/_journal.json
index 3222938..5df8cf2 100644
--- a/packages/database/migrations/sqlite/meta/_journal.json
+++ b/packages/database/migrations/sqlite/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1780851107505,
"tag": "0011_scheduled-tasks",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "6",
+ "when": 1781466219864,
+ "tag": "0012_user_additions",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts
index 5830ed0..3f65d80 100644
--- a/packages/database/src/schema.ts
+++ b/packages/database/src/schema.ts
@@ -7,11 +7,13 @@ const usePostgres = !!process.env.POSTGRES_URL;
// and are NOT part of the Drizzle schema. Only app-specific tables are exported here.
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
+export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders;
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
+export const userOnboarding = usePostgres ? postgresSchema.userOnboarding : sqliteSchema.userOnboarding;
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
diff --git a/packages/database/src/schema_postgres.ts b/packages/database/src/schema_postgres.ts
index b43b97b..6bb3676 100644
--- a/packages/database/src/schema_postgres.ts
+++ b/packages/database/src/schema_postgres.ts
@@ -4,6 +4,18 @@ import { user } from './schema_auth_postgres';
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
+export const userFolders = pgTable('user_folders', {
+ id: text('id').notNull(),
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
+ name: text('name').notNull(),
+ position: integer('position').notNull().default(0),
+ createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
+ updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
+}, (table) => [
+ primaryKey({ columns: [table.id, table.userId] }),
+ index('idx_user_folders_user_position').on(table.userId, table.position),
+]);
+
export const documents = pgTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@@ -12,11 +24,19 @@ export const documents = pgTable('documents', {
size: bigint('size', { mode: 'number' }).notNull(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').notNull(),
+ folderId: text('folder_id'),
+ recentlyOpenedAt: bigint('recently_opened_at', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
+ foreignKey({
+ columns: [table.folderId, table.userId],
+ foreignColumns: [userFolders.id, userFolders.userId],
+ }),
index('idx_documents_user_id').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
+ index('idx_documents_user_id_folder').on(table.userId, table.folderId),
+ index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
]);
export const audiobooks = pgTable('audiobooks', {
@@ -89,6 +109,14 @@ export const userPreferences = pgTable('user_preferences', {
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
});
+export const userOnboarding = pgTable('user_onboarding', {
+ userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
+ privacyAcceptedAtMs: bigint('privacy_accepted_at_ms', { mode: 'number' }),
+ lastSeenAppVersion: text('last_seen_app_version'),
+ createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
+ updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
+});
+
export const documentSettings = pgTable('document_settings', {
documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
diff --git a/packages/database/src/schema_sqlite.ts b/packages/database/src/schema_sqlite.ts
index 12f7ab7..f722133 100644
--- a/packages/database/src/schema_sqlite.ts
+++ b/packages/database/src/schema_sqlite.ts
@@ -4,6 +4,18 @@ import { user } from './schema_auth_sqlite';
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
+export const userFolders = sqliteTable('user_folders', {
+ id: text('id').notNull(),
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
+ name: text('name').notNull(),
+ position: integer('position').notNull().default(0),
+ createdAt: integer('created_at').default(SQLITE_NOW_MS),
+ updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
+}, (table) => [
+ primaryKey({ columns: [table.id, table.userId] }),
+ index('idx_user_folders_user_position').on(table.userId, table.position),
+]);
+
export const documents = sqliteTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@@ -12,11 +24,19 @@ export const documents = sqliteTable('documents', {
size: integer('size').notNull(),
lastModified: integer('last_modified').notNull(),
filePath: text('file_path').notNull(),
+ folderId: text('folder_id'),
+ recentlyOpenedAt: integer('recently_opened_at'),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
+ foreignKey({
+ columns: [table.folderId, table.userId],
+ foreignColumns: [userFolders.id, userFolders.userId],
+ }),
index('idx_documents_user_id').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
+ index('idx_documents_user_id_folder').on(table.userId, table.folderId),
+ index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
]);
export const audiobooks = sqliteTable('audiobooks', {
@@ -89,6 +109,14 @@ export const userPreferences = sqliteTable('user_preferences', {
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
});
+export const userOnboarding = sqliteTable('user_onboarding', {
+ userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
+ privacyAcceptedAtMs: integer('privacy_accepted_at_ms'),
+ lastSeenAppVersion: text('last_seen_app_version'),
+ createdAt: integer('created_at').default(SQLITE_NOW_MS),
+ updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
+});
+
export const documentSettings = sqliteTable('document_settings', {
documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ca9ff3..0164781 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,12 +59,6 @@ importers:
core-js:
specifier: ^3.49.0
version: 3.49.0
- dexie:
- specifier: ^4.4.3
- version: 4.4.3
- dexie-react-hooks:
- specifier: ^4.4.0
- version: 4.4.0(dexie@4.4.3)(react@19.2.7)
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -2606,15 +2600,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
- dexie-react-hooks@4.4.0:
- resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==}
- peerDependencies:
- dexie: '>=4.2.0-alpha.1 <5.0.0'
- react: '>=16'
-
- dexie@4.4.3:
- resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==}
-
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -7089,13 +7074,6 @@ snapshots:
dependencies:
dequal: 2.0.3
- dexie-react-hooks@4.4.0(dexie@4.4.3)(react@19.2.7):
- dependencies:
- dexie: 4.4.3
- react: 19.2.7
-
- dexie@4.4.3: {}
-
didyoumean@1.2.2: {}
dlv@1.1.3: {}
diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx
index 38472f8..8147a81 100644
--- a/src/app/(app)/epub/[id]/page.tsx
+++ b/src/app/(app)/epub/[id]/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useParams, useRouter } from "next/navigation";
+import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer';
@@ -14,7 +14,6 @@ import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
-import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@@ -26,7 +25,6 @@ import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
- const router = useRouter();
const routeDocumentId = typeof id === 'string' ? id : undefined;
const epubState = useEpubDocument(routeDocumentId);
const {
@@ -62,19 +60,13 @@ export default function EPUBPage() {
const loadDocument = useCallback(async () => {
console.log('Loading new epub (from page.tsx)');
- let didRedirect = false;
let startedLoad = false;
try {
if (!routeDocumentId) {
setError('Document not found');
return;
}
- const resolved = await resolveDocumentId(routeDocumentId);
- if (resolved !== routeDocumentId) {
- didRedirect = true;
- router.replace(`/epub/${resolved}`);
- return;
- }
+ const resolved = routeDocumentId;
if (loadedDocIdRef.current === resolved) {
return;
@@ -95,11 +87,11 @@ export default function EPUBPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
- if (!didRedirect && startedLoad) {
+ if (startedLoad) {
setIsLoading(false);
}
}
- }, [routeDocumentId, router, setCurrentDocument, stop]);
+ }, [routeDocumentId, setCurrentDocument, stop]);
useEffect(() => {
if (!isLoading) return;
diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts
index ee5167f..c67e6d1 100644
--- a/src/app/(app)/epub/[id]/useEpubDocument.ts
+++ b/src/app/(app)/epub/[id]/useEpubDocument.ts
@@ -115,8 +115,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
} = useTTS();
// Configuration context to get TTS settings
const {
- apiKey,
- baseUrl,
providerRef,
ttsSegmentMaxBlockLength,
epubTheme,
@@ -195,7 +193,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
}, [resetHighlightState, setCurrDocPages, stop]);
/**
- * Sets the current document based on its ID by fetching from IndexedDB
+ * Sets the current document based on its ID using server metadata and the browser blob cache.
* @param {string} id - The unique identifier of the document
* @throws {Error} When document data is empty or retrieval fails
*/
@@ -478,8 +476,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
bookRef,
tocRef,
- apiKey,
- baseUrl,
providerRef,
});
diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx
index 6d97618..5314dcd 100644
--- a/src/app/(app)/html/[id]/page.tsx
+++ b/src/app/(app)/html/[id]/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useParams, useRouter } from "next/navigation";
+import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer';
@@ -9,7 +9,6 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
-import { resolveDocumentId } from '@/lib/client/dexie';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
@@ -26,7 +25,6 @@ import { useHtmlDocument } from './useHtmlDocument';
export default function HTMLPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
- const router = useRouter();
const routeDocumentId = typeof id === 'string' ? id : undefined;
const htmlState = useHtmlDocument();
const {
@@ -63,19 +61,13 @@ export default function HTMLPage() {
const loadDocument = useCallback(async () => {
if (!isLoading) return;
console.log('Loading new HTML document (from page.tsx)');
- let didRedirect = false;
let startedLoad = false;
try {
if (!id) {
setError('Document not found');
return;
}
- const resolved = await resolveDocumentId(id as string);
- if (resolved !== (id as string)) {
- didRedirect = true;
- router.replace(`/html/${resolved}`);
- return;
- }
+ const resolved = id as string;
if (loadedDocIdRef.current === resolved) {
return;
@@ -96,11 +88,11 @@ export default function HTMLPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
- if (!didRedirect && startedLoad) {
+ if (startedLoad) {
setIsLoading(false);
}
}
- }, [isLoading, id, router, setCurrentDocument, stop]);
+ }, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => {
if (!isLoading) return;
diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts
index 3b91553..8543b06 100644
--- a/src/app/(app)/html/[id]/useHtmlDocument.ts
+++ b/src/app/(app)/html/[id]/useHtmlDocument.ts
@@ -66,8 +66,6 @@ function buildFullDocumentText(blocks: HtmlBlock[]): string {
export function useHtmlDocument(): HtmlDocumentState {
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
const {
- apiKey,
- baseUrl,
providerRef,
ttsSegmentMaxBlockLength,
} = useConfig();
@@ -173,8 +171,6 @@ export function useHtmlDocument(): HtmlDocumentState {
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@@ -189,7 +185,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error;
}
},
- [audiobookAdapter, apiKey, baseUrl, providerRef],
+ [audiobookAdapter, providerRef],
);
const regenerateChapter = useCallback(
@@ -208,8 +204,6 @@ export function useHtmlDocument(): HtmlDocumentState {
bookId,
format,
signal,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
settings,
retryOptions,
@@ -222,7 +216,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error;
}
},
- [audiobookAdapter, apiKey, baseUrl, providerRef],
+ [audiobookAdapter, providerRef],
);
return useMemo(
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index ebb1972..0ddd4e9 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -34,12 +34,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
- {/* ConfigProvider lives here, in the shared (app) layout, so it stays
- mounted across library <-> reader navigation. Mounting it per-route
- re-ran the Dexie/server hydration race on every navigation, causing
- the reader to briefly use the admin-default provider until a full
- page refresh. A single shared instance keeps the user's saved
- provider hydrated the whole time. */}
+ {/* Keep the preferences query/context mounted across library and reader navigation. */}
{children}
diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx
index 8fd9b54..7540377 100644
--- a/src/app/(app)/pdf/[id]/page.tsx
+++ b/src/app/(app)/pdf/[id]/page.tsx
@@ -14,7 +14,6 @@ import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
-import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@@ -99,7 +98,6 @@ export default function PDFViewerPage() {
const loadDocument = useCallback(async () => {
if (!isLoading) return; // Prevent calls when not loading new doc
console.log('Loading new document (from page.tsx)');
- let didRedirect = false;
let startedLoad = false;
let loadSucceeded = false;
try {
@@ -107,12 +105,7 @@ export default function PDFViewerPage() {
setError('Document not found');
return;
}
- const resolved = await resolveDocumentId(id as string);
- if (resolved !== (id as string)) {
- didRedirect = true;
- router.replace(`/pdf/${resolved}`);
- return;
- }
+ const resolved = id as string;
if (loadedDocIdRef.current === resolved) {
return;
@@ -125,12 +118,18 @@ export default function PDFViewerPage() {
inFlightDocIdRef.current = resolved;
stop(); // Reset TTS when loading new document
for (let attempt = 0; attempt < 2; attempt += 1) {
- const loaded = await setCurrentDocument(resolved);
- if (loaded) {
+ const result = await setCurrentDocument(resolved);
+ if (result === 'loaded') {
loadSucceeded = true;
loadedDocIdRef.current = resolved;
break;
}
+ if (result === 'superseded') {
+ // A newer load (or unmount) is now authoritative; it owns the loading
+ // lifecycle. Bail without surfacing an error to avoid the spurious
+ // "Failed to load" screen on first launch.
+ return;
+ }
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
@@ -145,11 +144,11 @@ export default function PDFViewerPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
- if (!didRedirect && startedLoad && loadSucceeded) {
+ if (startedLoad && loadSucceeded) {
setIsLoading(false);
}
}
- }, [isLoading, id, router, setCurrentDocument, stop]);
+ }, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => {
loadDocument();
diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts
index 86cebf9..5be4ed4 100644
--- a/src/app/(app)/pdf/[id]/usePdfDocument.ts
+++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts
@@ -22,10 +22,8 @@ import {
ensureParsedPdfDocumentOperation,
forceReparsePdfDocument,
getDocumentMetadata,
- getDocumentSettings,
getParsedPdfDocument,
ParsedPdfNotReadyError,
- putDocumentSettings,
subscribeParsedPdfDocumentEvents,
} from '@/lib/client/api/documents';
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
@@ -56,6 +54,16 @@ import type {
} from '@/types/tts';
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
import { clampSegmentPreloadDepth } from '@/types/config';
+import { useDocumentSettings } from '@/hooks/useDocumentSettings';
+
+/**
+ * Outcome of a `setCurrentDocument` call.
+ * - `loaded`: the document was fetched and is now the active document.
+ * - `superseded`: the load was aborted/replaced by a newer load (or unmount).
+ * A newer load is authoritative; callers must NOT treat this as an error.
+ * - `failed`: a genuine failure (not found, wrong type, network error).
+ */
+export type SetCurrentDocumentResult = 'loaded' | 'superseded' | 'failed';
/**
* Interface defining all available methods and properties for the PDF route.
@@ -78,7 +86,7 @@ export interface PdfDocumentState {
parsedOverlayEnabled: boolean;
setParsedOverlayEnabled: (enabled: boolean) => void;
forceReparseParsedPdf: () => Promise;
- setCurrentDocument: (id: string) => Promise;
+ setCurrentDocument: (id: string) => Promise;
clearCurrDoc: () => void;
// PDF functionality
@@ -139,8 +147,6 @@ export function usePdfDocument(): PdfDocumentState {
registerVisualPageChangeHandler,
} = useTTS();
const {
- apiKey,
- baseUrl,
providerRef,
segmentPreloadDepthPages,
ttsSegmentMaxBlockLength,
@@ -158,6 +164,11 @@ export function usePdfDocument(): PdfDocumentState {
const [parseProgress, setParseProgress] = useState(null);
const [, setActiveParseOpId] = useState(null);
const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS);
+ const serverDocumentSettings = useDocumentSettings(currDocId);
+ useEffect(() => {
+ if (!serverDocumentSettings.query.data?.settings) return;
+ setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, serverDocumentSettings.query.data.settings));
+ }, [serverDocumentSettings.query.data?.settings]);
useEffect(() => {
setDocumentLanguage(documentSettings.language ?? 'auto');
lastPreparedPlaybackPageRef.current = null;
@@ -206,17 +217,6 @@ export function usePdfDocument(): PdfDocumentState {
pageTextCacheRef.current.clear();
}, []);
- const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise => {
- try {
- const response = await getDocumentSettings(documentId, { signal });
- setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
- } catch (error) {
- if (error instanceof DOMException && error.name === 'AbortError') return;
- console.warn('Failed to load document settings, using defaults:', error);
- setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
- }
- }, []);
-
const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => {
parseStreamAbortRef.current?.abort();
parseSseCloseRef.current?.();
@@ -516,12 +516,12 @@ export function usePdfDocument(): PdfDocumentState {
/**
* Sets the current document based on its ID
- * Retrieves document from IndexedDB
+ * Retrieves document from server metadata and the browser blob cache.
*
* @param {string} id - The unique identifier of the document to set
* @returns {Promise}
*/
- const setCurrentDocument = useCallback(async (id: string): Promise => {
+ const setCurrentDocument = useCallback(async (id: string): Promise => {
// --- race-condition guard ---
const seq = ++docLoadSeqRef.current;
docLoadAbortRef.current?.abort();
@@ -554,13 +554,12 @@ export function usePdfDocument(): PdfDocumentState {
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
const meta = await getDocumentMetadata(id, { signal: controller.signal });
- if (seq !== docLoadSeqRef.current) return false; // stale
+ if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
if (!meta) {
console.error('Document not found on server');
- return false;
+ return 'failed';
}
if (meta.type === 'pdf') {
- void fetchDocumentSettings(id, controller.signal);
void resolveParsedDocumentState(id, controller.signal).catch((error) => {
if (controller.signal.aborted) return;
console.error('Failed to resolve parsed PDF state:', error);
@@ -568,28 +567,29 @@ export function usePdfDocument(): PdfDocumentState {
}
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
- if (seq !== docLoadSeqRef.current) return false; // stale
+ if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
if (doc.type !== 'pdf') {
console.error('Document is not a PDF');
- return false;
+ return 'failed';
}
setCurrDocName(doc.name);
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
// buffer passed into the worker; we always pass clones to react-pdf.
setCurrDocData(doc.data.slice(0));
- return true;
+ return 'loaded';
} catch (error) {
- if (error instanceof DOMException && error.name === 'AbortError') return false;
+ // An aborted load means a newer selection (or unmount) took over; not a failure.
+ if (error instanceof DOMException && error.name === 'AbortError') return 'superseded';
+ if (controller.signal.aborted) return 'superseded';
console.error('Failed to get document:', error);
- return false;
+ return 'failed';
} 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;
}
}
- return false;
}, [
setCurrDocId,
setCurrDocName,
@@ -597,7 +597,6 @@ export function usePdfDocument(): PdfDocumentState {
setCurrDocPages,
setCurrDocText,
setPdfDocument,
- fetchDocumentSettings,
resolveParsedDocumentState,
]);
@@ -605,12 +604,11 @@ export function usePdfDocument(): PdfDocumentState {
if (!currDocId) return;
setDocumentSettings(settings);
try {
- const updated = await putDocumentSettings(currDocId, settings);
- setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings));
+ await serverDocumentSettings.mutation.mutateAsync(settings);
} catch (error) {
console.warn('Failed to persist document settings:', error);
}
- }, [currDocId]);
+ }, [currDocId, serverDocumentSettings.mutation]);
const forceReparseParsedPdf = useCallback(async (): Promise => {
if (!currDocId) return;
@@ -684,8 +682,6 @@ export function usePdfDocument(): PdfDocumentState {
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@@ -698,7 +694,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error creating audiobook:', error);
throw error;
}
- }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
+ }, [audiobookAdapter, providerRef]);
/**
* Regenerates a specific chapter (page) of the PDF audiobook
@@ -717,8 +713,6 @@ export function usePdfDocument(): PdfDocumentState {
bookId,
format,
signal,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
settings,
});
@@ -729,7 +723,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error regenerating page:', error);
throw error;
}
- }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
+ }, [audiobookAdapter, providerRef]);
/**
* Effect hook to initialize TTS as non-EPUB mode
diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts
index f080fbe..2cc056f 100644
--- a/src/app/api/audiobook/chapter/route.ts
+++ b/src/app/api/audiobook/chapter/route.ts
@@ -477,10 +477,7 @@ export async function POST(request: NextRequest) {
providerForError = requestedProvider;
const credResolved = await resolveTtsCredentials({
providerHeader: requestedProvider,
- apiKeyHeader: request.headers.get('x-openai-key'),
- baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
- restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in credResolved) {
if (credResolved.error === 'no_shared_provider_configured') {
diff --git a/src/app/api/documents/[id]/opened/route.ts b/src/app/api/documents/[id]/opened/route.ts
new file mode 100644
index 0000000..c864d3e
--- /dev/null
+++ b/src/app/api/documents/[id]/opened/route.ts
@@ -0,0 +1,27 @@
+import { and, eq } from 'drizzle-orm';
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@openreader/database';
+import { documents } from '@openreader/database/schema';
+import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
+import { nowTimestampMs } from '@/lib/shared/timestamps';
+import { errorResponse } from '@/lib/server/errors/next-response';
+
+export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const { id } = await ctx.params;
+ const recentlyOpenedAt = nowTimestampMs();
+ const rows = await db.update(documents).set({ recentlyOpenedAt }).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ eq(documents.id, id.toLowerCase()),
+ )).returning({ id: documents.id });
+ if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
+ return NextResponse.json({ documentId: rows[0].id, recentlyOpenedAt });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to update recently opened state',
+ normalize: { code: 'DOCUMENT_OPENED_UPDATE_FAILED', errorClass: 'db' },
+ });
+ }
+}
diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts
index 1fa3450..6638aab 100644
--- a/src/app/api/documents/blob/preview/ensure/route.ts
+++ b/src/app/api/documents/blob/preview/ensure/route.ts
@@ -45,6 +45,7 @@ export async function GET(req: NextRequest) {
status: 'ready',
presignUrl,
fallbackUrl,
+ previewVersion: preview.eTag || String(doc.lastModified),
...(directUrl ? { directUrl } : {}),
},
{
diff --git a/src/app/api/documents/folders/route.ts b/src/app/api/documents/folders/route.ts
new file mode 100644
index 0000000..4c1240c
--- /dev/null
+++ b/src/app/api/documents/folders/route.ts
@@ -0,0 +1,43 @@
+import { and, eq, inArray } from 'drizzle-orm';
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@openreader/database';
+import { documents, userFolders } from '@openreader/database/schema';
+import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
+import { errorResponse } from '@/lib/server/errors/next-response';
+
+export async function PATCH(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const body = (await req.json().catch(() => null)) as { documentIds?: unknown; folderId?: unknown } | null;
+ const documentIds = Array.isArray(body?.documentIds)
+ ? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
+ : [];
+ const folderId = body?.folderId === null ? null : typeof body?.folderId === 'string' ? body.folderId : undefined;
+ if (documentIds.length === 0 || folderId === undefined) {
+ return NextResponse.json({ error: 'documentIds and folderId are required' }, { status: 400 });
+ }
+ const owned = await db.select({ id: documents.id }).from(documents).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ inArray(documents.id, documentIds),
+ ));
+ if (owned.length !== documentIds.length) return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
+ if (folderId) {
+ const folder = await db.select({ id: userFolders.id }).from(userFolders).where(and(
+ eq(userFolders.userId, scope.ownerUserId),
+ eq(userFolders.id, folderId),
+ )).limit(1);
+ if (!folder[0]) return NextResponse.json({ error: 'Folder not found' }, { status: 404 });
+ }
+ await db.update(documents).set({ folderId }).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ inArray(documents.id, documentIds),
+ ));
+ return NextResponse.json({ success: true, documentIds, folderId });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to move documents',
+ normalize: { code: 'DOCUMENT_FOLDER_UPDATE_FAILED', errorClass: 'db' },
+ });
+ }
+}
diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts
index 328591f..90ed50d 100644
--- a/src/app/api/documents/route.ts
+++ b/src/app/api/documents/route.ts
@@ -64,6 +64,8 @@ export async function GET(req: NextRequest) {
size: number;
lastModified: number;
filePath: string;
+ folderId: string | null;
+ recentlyOpenedAt: number | null;
}>;
const results: BaseDocument[] = rows.map((doc) => {
@@ -75,6 +77,9 @@ export async function GET(req: NextRequest) {
lastModified: Number(doc.lastModified),
type,
scope: 'user',
+ folderId: doc.folderId ?? undefined,
+ recentlyOpenedAt: doc.recentlyOpenedAt == null ? undefined : Number(doc.recentlyOpenedAt),
+ contentVersion: doc.id,
};
});
diff --git a/src/app/api/folders/[id]/route.ts b/src/app/api/folders/[id]/route.ts
new file mode 100644
index 0000000..3e6a6e7
--- /dev/null
+++ b/src/app/api/folders/[id]/route.ts
@@ -0,0 +1,52 @@
+import { and, eq } from 'drizzle-orm';
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@openreader/database';
+import { documents, userFolders } from '@openreader/database/schema';
+import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
+import { nowTimestampMs } from '@/lib/shared/timestamps';
+import { errorResponse } from '@/lib/server/errors/next-response';
+
+export const dynamic = 'force-dynamic';
+
+export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const { id } = await ctx.params;
+ const body = (await req.json().catch(() => null)) as { name?: unknown; position?: unknown } | null;
+ const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : undefined;
+ const position = Number.isFinite(body?.position) ? Number(body?.position) : undefined;
+ if (!name && position === undefined) return NextResponse.json({ error: 'No valid fields provided' }, { status: 400 });
+ const rows = await db.update(userFolders).set({
+ ...(name ? { name } : {}),
+ ...(position !== undefined ? { position } : {}),
+ updatedAt: nowTimestampMs(),
+ }).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId))).returning();
+ if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
+ return NextResponse.json({ folder: rows[0] });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to update folder',
+ normalize: { code: 'FOLDER_UPDATE_FAILED', errorClass: 'db' },
+ });
+ }
+}
+
+export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const { id } = await ctx.params;
+ await db.update(documents).set({ folderId: null }).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ eq(documents.folderId, id),
+ ));
+ await db.delete(userFolders).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId)));
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to delete folder',
+ normalize: { code: 'FOLDER_DELETE_FAILED', errorClass: 'db' },
+ });
+ }
+}
diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts
new file mode 100644
index 0000000..1f9b24e
--- /dev/null
+++ b/src/app/api/folders/route.ts
@@ -0,0 +1,81 @@
+import { and, asc, eq, inArray } from 'drizzle-orm';
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@openreader/database';
+import { documents, userFolders } from '@openreader/database/schema';
+import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
+import { nowTimestampMs } from '@/lib/shared/timestamps';
+import { errorResponse } from '@/lib/server/errors/next-response';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const rows = await db.select().from(userFolders)
+ .where(eq(userFolders.userId, scope.ownerUserId))
+ .orderBy(asc(userFolders.position), asc(userFolders.createdAt));
+ return NextResponse.json({ folders: rows });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to load folders',
+ normalize: { code: 'FOLDERS_LOAD_FAILED', errorClass: 'db' },
+ });
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const body = (await req.json().catch(() => null)) as { id?: unknown; name?: unknown; documentIds?: unknown } | null;
+ const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : '';
+ if (!name) return NextResponse.json({ error: 'Folder name is required' }, { status: 400 });
+ const documentIds = Array.isArray(body?.documentIds)
+ ? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
+ : [];
+ if (documentIds.length > 0) {
+ const owned = await db.select({ id: documents.id }).from(documents).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ inArray(documents.id, documentIds),
+ ));
+ if (owned.length !== documentIds.length) {
+ return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
+ }
+ }
+ const id = typeof body?.id === 'string' && /^[a-zA-Z0-9-]{1,100}$/.test(body.id)
+ ? body.id
+ : crypto.randomUUID();
+ const now = nowTimestampMs();
+ await db.transaction(async (tx: typeof db) => {
+ await tx.insert(userFolders).values({ id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now });
+ if (documentIds.length > 0) {
+ await tx.update(documents).set({ folderId: id }).where(and(
+ eq(documents.userId, scope.ownerUserId),
+ inArray(documents.id, documentIds),
+ ));
+ }
+ });
+ return NextResponse.json({ folder: { id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now } });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to create folder',
+ normalize: { code: 'FOLDER_CREATE_FAILED', errorClass: 'db' },
+ });
+ }
+}
+
+export async function DELETE(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ await db.update(documents).set({ folderId: null }).where(eq(documents.userId, scope.ownerUserId));
+ await db.delete(userFolders).where(eq(userFolders.userId, scope.ownerUserId));
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to delete folders',
+ normalize: { code: 'FOLDERS_DELETE_FAILED', errorClass: 'db' },
+ });
+ }
+}
diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts
index f93e551..cb81c75 100644
--- a/src/app/api/tts/segments/ensure/route.ts
+++ b/src/app/api/tts/segments/ensure/route.ts
@@ -183,10 +183,7 @@ export async function POST(request: NextRequest) {
});
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.providerRef,
- apiKeyHeader: request.headers.get('x-openai-key'),
- baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
- restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in requestCreds) {
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;
diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts
index 1c31b39..991167e 100644
--- a/src/app/api/tts/voices/route.ts
+++ b/src/app/api/tts/voices/route.ts
@@ -20,10 +20,7 @@ export async function GET(req: NextRequest) {
const runtimeConfig = await getResolvedRuntimeConfig();
const resolved = await resolveTtsCredentials({
providerHeader: req.headers.get('x-tts-provider'),
- apiKeyHeader: req.headers.get('x-openai-key'),
- baseUrlHeader: req.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
- restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in resolved) {
diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts
index 8d4b918..b89d653 100644
--- a/src/app/api/user/claim/route.ts
+++ b/src/app/api/user/claim/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData } from '@/lib/server/user/claim-data';
import { auth } from '@/lib/server/auth/auth';
import { db } from '@openreader/database';
-import { audiobooks, documentSettings, documents, userDocumentProgress, userPreferences } from '@openreader/database/schema';
+import { audiobooks, documentSettings, documents, userDocumentProgress, userFolders, userOnboarding, userPreferences } from '@openreader/database/schema';
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@@ -26,14 +26,16 @@ async function checkClaimMigrationReadiness(): Promise {
async function getClaimableCounts(
unclaimedUserId: string,
-): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
- const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
+): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> {
+ const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] =
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)),
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
+ db.select({ count: count() }).from(userFolders).where(eq(userFolders.userId, unclaimedUserId)),
+ db.select({ count: count() }).from(userOnboarding).where(eq(userOnboarding.userId, unclaimedUserId)),
]);
return {
@@ -42,6 +44,8 @@ async function getClaimableCounts(
preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0),
documentSettings: Number(settingsCount?.count ?? 0),
+ folders: Number(folderCount?.count ?? 0),
+ onboarding: Number(onboardingCount?.count ?? 0),
};
}
diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts
index eae1047..605b950 100644
--- a/src/app/api/user/export/route.ts
+++ b/src/app/api/user/export/route.ts
@@ -12,6 +12,8 @@ import {
userDocumentProgress,
userJobEvents,
userPreferences,
+ userFolders,
+ userOnboarding,
userTtsChars,
} from '@openreader/database/schema';
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
@@ -65,6 +67,8 @@ export async function GET(req: NextRequest) {
segmentVariants,
userDocs,
userAudiobooks,
+ folders,
+ onboarding,
] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db
@@ -107,6 +111,8 @@ export async function GET(req: NextRequest) {
.from(audiobooks)
.where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)),
+ db.select().from(userFolders).where(eq(userFolders.userId, userId)).orderBy(userFolders.position),
+ db.select().from(userOnboarding).where(eq(userOnboarding.userId, userId)).limit(1),
]);
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
@@ -190,6 +196,8 @@ export async function GET(req: NextRequest) {
exportedAtMs,
profileData,
preferences: prefs[0] ?? null,
+ folders,
+ onboarding: onboarding[0] ?? null,
readingHistory: progress,
ttsUsage,
jobEvents,
diff --git a/src/app/api/user/state/changelog/version-check/route.ts b/src/app/api/user/state/changelog/version-check/route.ts
index 498467b..b25a8b1 100644
--- a/src/app/api/user/state/changelog/version-check/route.ts
+++ b/src/app/api/user/state/changelog/version-check/route.ts
@@ -1,26 +1,15 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
-import { userPreferences } from '@openreader/database/schema';
+import { userOnboarding } from '@openreader/database/schema';
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
-import {
- deserializeUserPreferencesPayload,
- extractUserPreferencesMeta,
- withUserPreferencesMeta,
- USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY,
-} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic';
-function serializePreferencesForDb(payload: Record): Record | string {
- if (process.env.POSTGRES_URL) return payload;
- return JSON.stringify(payload);
-}
-
export async function POST(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
@@ -38,40 +27,28 @@ export async function POST(req: NextRequest) {
const rows = await db
.select({
- dataJson: userPreferences.dataJson,
- clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
+ lastSeenAppVersion: userOnboarding.lastSeenAppVersion,
})
- .from(userPreferences)
- .where(eq(userPreferences.userId, scope.ownerUserId))
+ .from(userOnboarding)
+ .where(eq(userOnboarding.userId, scope.ownerUserId))
.limit(1);
const row = rows[0];
- const existingPayload = deserializeUserPreferencesPayload(row?.dataJson);
- const existingMeta = extractUserPreferencesMeta(existingPayload);
- const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string'
- ? existingMeta.lastSeenAppVersion
- : null;
+ const lastSeenVersion = row?.lastSeenAppVersion ?? null;
const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion);
- const nextMeta = {
- ...existingMeta,
- [USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion,
- };
- const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta));
const updatedAt = nowTimestampMs();
- const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
await db
- .insert(userPreferences)
+ .insert(userOnboarding)
.values({
userId: scope.ownerUserId,
- dataJson,
- clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
+ lastSeenAppVersion: currentVersion,
updatedAt,
})
.onConflictDoUpdate({
- target: [userPreferences.userId],
+ target: [userOnboarding.userId],
set: {
- dataJson,
+ lastSeenAppVersion: currentVersion,
updatedAt,
},
});
diff --git a/src/app/api/user/state/onboarding/route.ts b/src/app/api/user/state/onboarding/route.ts
new file mode 100644
index 0000000..451b5c3
--- /dev/null
+++ b/src/app/api/user/state/onboarding/route.ts
@@ -0,0 +1,78 @@
+import { eq } from 'drizzle-orm';
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@openreader/database';
+import { userOnboarding } from '@openreader/database/schema';
+import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
+import { nowTimestampMs } from '@/lib/shared/timestamps';
+import { errorResponse } from '@/lib/server/errors/next-response';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const rows = await db.select().from(userOnboarding)
+ .where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
+ const row = rows[0];
+ return NextResponse.json({
+ onboarding: {
+ privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
+ lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
+ },
+ });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to load onboarding state',
+ normalize: { code: 'USER_ONBOARDING_LOAD_FAILED', errorClass: 'db' },
+ });
+ }
+}
+
+export async function PATCH(req: NextRequest) {
+ try {
+ const scope = await resolveUserStateScope(req);
+ if (scope instanceof Response) return scope;
+ const body = (await req.json().catch(() => null)) as {
+ privacyAccepted?: unknown;
+ lastSeenAppVersion?: unknown;
+ } | null;
+ const now = nowTimestampMs();
+ const privacyAcceptedAtMs = body?.privacyAccepted === true
+ ? now
+ : body?.privacyAccepted === false ? null : undefined;
+ const lastSeenAppVersion = typeof body?.lastSeenAppVersion === 'string'
+ ? body.lastSeenAppVersion.trim() || null
+ : undefined;
+ if (privacyAcceptedAtMs === undefined && lastSeenAppVersion === undefined) {
+ return NextResponse.json({ error: 'No valid onboarding fields provided' }, { status: 400 });
+ }
+ await db.insert(userOnboarding).values({
+ userId: scope.ownerUserId,
+ privacyAcceptedAtMs: privacyAcceptedAtMs ?? null,
+ lastSeenAppVersion: lastSeenAppVersion ?? null,
+ updatedAt: now,
+ }).onConflictDoUpdate({
+ target: [userOnboarding.userId],
+ set: {
+ ...(privacyAcceptedAtMs !== undefined ? { privacyAcceptedAtMs } : {}),
+ ...(lastSeenAppVersion !== undefined ? { lastSeenAppVersion } : {}),
+ updatedAt: now,
+ },
+ });
+ const rows = await db.select().from(userOnboarding)
+ .where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
+ const row = rows[0];
+ return NextResponse.json({
+ onboarding: {
+ privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
+ lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
+ },
+ });
+ } catch (error) {
+ return errorResponse(error, {
+ apiErrorMessage: 'Failed to update onboarding state',
+ normalize: { code: 'USER_ONBOARDING_UPDATE_FAILED', errorClass: 'db' },
+ });
+ }
+}
diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts
index 4c81de2..98d684f 100644
--- a/src/app/api/user/state/preferences/route.ts
+++ b/src/app/api/user/state/preferences/route.ts
@@ -13,12 +13,6 @@ import {
sanitizePreferencesPatch,
type PreferenceNormalizationContext,
} from '@/lib/server/user/preferences-normalize';
-import {
- deserializeUserPreferencesPayload,
- extractUserPreferencesMeta,
- withUserPreferencesMeta,
- type UserPreferencesMeta,
-} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic';
@@ -27,6 +21,22 @@ function serializePreferencesForDb(payload: Record): Record {
+ if (typeof value === 'string') {
+ try {
+ const parsed = JSON.parse(value);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed as Record
+ : {};
+ } catch {
+ return {};
+ }
+ }
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : {};
+}
+
async function loadPreferenceNormalizationContext(): Promise {
const [runtimeConfig, providers] = await Promise.all([
getResolvedRuntimeConfig(),
@@ -49,11 +59,10 @@ async function loadPreferenceNormalizationContext(): Promise 0 ? clientUpdatedAtMs : updatedAt,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
- dataJson: serializePreferencesForDb(storedPayload),
+ dataJson: serializePreferencesForDb(storedPatch),
updatedAt,
},
});
@@ -161,8 +169,7 @@ export async function PUT(req: NextRequest) {
}
const mergedPatch = { ...existingPatch, ...patch };
- const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
- const dataJson = serializePreferencesForDb(payloadWithMeta);
+ const dataJson = serializePreferencesForDb(mergedPatch);
const updatedAt = nowTimestampMs();
await db
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index b8bc6f7..d9a9df0 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -1,6 +1,6 @@
'use client';
-import { ReactNode, useState } from 'react';
+import { ReactNode, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/contexts/ThemeContext';
@@ -25,6 +25,15 @@ export function Providers({ children, authBaseUrl, allowAnonymousAuthSessions, g
},
}));
+ useEffect(() => {
+ if (typeof indexedDB === 'undefined') return;
+ try {
+ indexedDB.deleteDatabase('openreader-db');
+ } catch {
+ // Legacy IndexedDB cleanup is best effort and never blocks startup.
+ }
+ }, []);
+
return (
diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx
index a82e172..0ef0302 100644
--- a/src/components/PrivacyModal.tsx
+++ b/src/components/PrivacyModal.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
-import { updateAppConfig } from '@/lib/client/dexie';
+import { useOnboardingState } from '@/hooks/useOnboardingState';
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps {
@@ -47,6 +47,7 @@ function PrivacyModalBody({ origin }: { origin: string }) {
}
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
+ const { mutation } = useOnboardingState();
const [origin, setOrigin] = useState('');
const [agreed, setAgreed] = useState(false);
@@ -62,7 +63,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
}, [isOpen]);
const handleAccept = async () => {
- await updateAppConfig({ privacyAccepted: true });
+ await mutation.mutateAsync({ privacyAccepted: true });
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('openreader:privacyAccepted'));
}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index fb80ca3..c6088a5 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -22,13 +22,8 @@ import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
+import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import {
- isBuiltInTtsProviderId,
- type TtsProviderType,
-} from '@/lib/shared/tts-provider-catalog';
-import {
- defaultBaseUrlForProviderType,
- defaultModelForProviderType,
resolveProviderDefaults,
resolveEffectiveProviderType,
resolveTtsProviderModelPolicy,
@@ -38,8 +33,6 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
-import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
-import toast from 'react-hot-toast';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
SidebarDialog,
@@ -219,7 +212,6 @@ export function SettingsModal({
const runtimeConfig = useRuntimeConfig();
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
- const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const isOpen = open;
const setIsOpen = onOpenChange;
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
@@ -228,10 +220,8 @@ export function SettingsModal({
const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
- const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
+ const { providerRef, providerType, ttsModel, ttsInstructions, updateConfigKey } = useConfig();
const { refreshDocuments } = useDocuments();
- const [localApiKey, setLocalApiKey] = useState(apiKey);
- const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
const [localProviderType, setLocalProviderType] = useState(providerType);
const [modelValue, setModelValue] = useState(ttsModel);
@@ -279,13 +269,12 @@ export function SettingsModal({
} = useMemo(() => resolveTtsSettingsViewModel({
providerRef: localProviderRef,
providerType: localProviderType,
- apiKey: localApiKey,
modelValue,
customModelInput,
showAllProviderModels,
sharedProviders,
- allowBuiltInProviders: !restrictUserApiKeys,
- }), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
+ allowBuiltInProviders: false,
+ }), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]);
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
@@ -301,13 +290,11 @@ export function SettingsModal({
// focus refetch in ConfigContext, or async shared-provider loading) must not
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
if (isOpen) return;
- setLocalApiKey(apiKey);
- setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
- }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
+ }, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@@ -331,17 +318,9 @@ export function SettingsModal({
setModelValue(shared.defaultModel);
}
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
- setLocalApiKey('');
- setLocalBaseUrl('');
setCustomModelInput('');
return;
}
-
- if (isBuiltInTtsProviderId(fallback.providerType)) {
- setModelValue(defaultModelForProviderType(fallback.providerType));
- setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
- setCustomModelInput('');
- }
}, [selectedProviderOption, ttsProviders, sharedProviders]);
const handleRefresh = async () => {
@@ -459,19 +438,9 @@ export function SettingsModal({
setShowDeleteAccountConfirm(false);
};
- const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
- if (type === 'apiKey') {
- setLocalApiKey(value === '' ? '' : value);
- } else if (type === 'baseUrl') {
- setLocalBaseUrl(value === '' ? '' : value);
- }
- };
-
const resetToCurrent = useCallback(() => {
setIsOpen(false);
setIsChangelogOpen(false);
- setLocalApiKey(apiKey);
- setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
@@ -481,7 +450,7 @@ export function SettingsModal({
} else {
setCustomModelInput('');
}
- }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
+ }, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -546,13 +515,6 @@ export function SettingsModal({
model: modelValue,
sharedProviders,
});
- const shouldShowBaseUrl = !restrictUserApiKeys
- && !isSharedSelected
- && providerModelPolicy.isResolvedProviderType
- && providerModelPolicy.providerType !== 'replicate'
- && providerModelPolicy.providerType !== 'speech-sdk'
- && (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
- const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':')
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
@@ -642,53 +604,11 @@ export function SettingsModal({
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setLocalTTSInstructions(defaults.defaultInstructions);
- if (provider.shared) {
- // Shared admin provider — credentials live on the server.
- setLocalApiKey('');
- setLocalBaseUrl('');
- } else if (isBuiltInTtsProviderId(provider.providerType)) {
- setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
- }
setCustomModelInput('');
}}
/>
)}
- {restrictUserApiKeys && (
-
- This instance restricts user API keys. TTS runs through admin-configured shared providers only.
-
- )}
-
- {shouldShowBaseUrl && (
-
-
- API Base URL
- {localBaseUrl && (Overriding env) }
-
- handleInputChange('baseUrl', e.target.value)}
- placeholder="Using environment variable"
- />
-
- )}
-
- {shouldShowApiKey && (
-
-
- API Key
- {localApiKey && (Overriding env) }
-
- handleInputChange('apiKey', e.target.value)}
- placeholder="Using environment variable"
- />
-
- )}
{isSharedSelected && (
This is a shared provider configured by an admin. API key and base URL are managed server-side.
@@ -773,8 +693,6 @@ export function SettingsModal({
providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders,
});
- setLocalApiKey('');
- setLocalBaseUrl('');
setLocalProviderRef(defaults.providerRef);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
@@ -796,10 +714,6 @@ export function SettingsModal({
providerType: selectedProviderType,
sharedProviders,
});
- await updateConfig({
- apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
- baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
- });
await updateConfigKey('providerRef', selectedProviderRef);
await updateConfigKey('providerType', selectedProviderType);
const finalModel = showAllProviderModels
@@ -807,16 +721,6 @@ export function SettingsModal({
: defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions);
- // Push the change to the server immediately rather than waiting on
- // the debounce, so a quick reload can't lose the save. Surface
- // failures instead of silently dropping them.
- try {
- await flushUserPreferencesSync();
- } catch (error) {
- console.error('Failed to save TTS provider settings:', error);
- toast.error('Failed to save provider settings. Please try again.');
- return;
- }
setIsOpen(false);
}}
>
diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx
index f1338da..faefb2d 100644
--- a/src/components/auth/ClaimDataModal.tsx
+++ b/src/components/auth/ClaimDataModal.tsx
@@ -11,6 +11,8 @@ export type ClaimableCounts = {
preferences: number;
progress: number;
documentSettings: number;
+ folders: number;
+ onboarding: number;
};
function toClaimableCounts(value: unknown): ClaimableCounts {
@@ -21,6 +23,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
+ folders: Number(rec.folders ?? 0),
+ onboarding: Number(rec.onboarding ?? 0),
};
}
@@ -86,6 +90,8 @@ export default function ClaimDataModal({
{claimableCounts.preferences} preference set(s)
{claimableCounts.progress} reading progress record(s)
{claimableCounts.documentSettings} document setting(s)
+ {claimableCounts.folders} folder(s)
+ {claimableCounts.onboarding} onboarding state record(s)
diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx
index f5ee2f6..c25a498 100644
--- a/src/components/doclist/DocumentList.tsx
+++ b/src/components/doclist/DocumentList.tsx
@@ -1,7 +1,6 @@
'use client';
-import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useDocuments } from '@/contexts/DocumentContext';
import type {
DocumentListDocument,
@@ -13,11 +12,9 @@ import type {
SortDirection,
ViewMode,
} from '@/types/documents';
-import {
- getDocumentListState,
- getDocumentRecentlyOpenedMap,
- saveDocumentListState,
-} from '@/lib/client/dexie';
+import { useFolders } from '@/hooks/useFolders';
+import { useUserPreferences } from '@/hooks/useUserPreferences';
+import { useAuthSession } from '@/hooks/useAuthSession';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
@@ -202,7 +199,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode),
);
const [iconSize, setIconSize] = useState(cachedState?.iconSize ?? DEFAULT_STATE.iconSize);
- const [folders, setFolders] = useState(cachedState?.folders ?? DEFAULT_STATE.folders);
+ const [folders, setFolders] = useState([]);
const [showHint, setShowHint] = useState(cachedState?.showHint ?? true);
const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState(cachedState?.sidebarFilter ?? 'all');
@@ -213,6 +210,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
+ const preferenceWriteTimer = useRef | null>(null);
const [documentToDelete, setDocumentToDelete] = useState(null);
const [pendingMerge, setPendingMerge] = useState<
@@ -235,18 +233,20 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
deleteDocument,
isHTMLLoading,
} = useDocuments();
+ const { data: session, isPending: isSessionPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
+ const { query: preferencesQuery, mutation: preferencesMutation } = useUserPreferences(sessionId, !isSessionPending);
+ const persistPreferences = preferencesMutation.mutate;
+ const folderState = useFolders();
// Load saved state.
useEffect(() => {
- let cancelled = false;
- (async () => {
- const saved = await getDocumentListState();
- if (cancelled) return;
+ if (preferencesQuery.isPending) return;
+ const saved = preferencesQuery.data?.preferences.documentListState;
if (saved) {
cachedDocumentListState = saved;
setSortBy(saved.sortBy);
setSortDirection(saved.sortDirection);
- setFolders(saved.folders ?? []);
setShowHint(saved.showHint ?? true);
setViewMode(normalizeViewMode(saved.viewMode));
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
@@ -257,7 +257,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
cachedDocumentListState = null;
setSortBy(DEFAULT_STATE.sortBy);
setSortDirection(DEFAULT_STATE.sortDirection);
- setFolders(DEFAULT_STATE.folders);
setShowHint(DEFAULT_STATE.showHint);
setViewMode(DEFAULT_STATE.viewMode);
setIconSize(DEFAULT_STATE.iconSize);
@@ -266,11 +265,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed);
}
setIsInitialized(true);
- })();
- return () => {
- cancelled = true;
- };
- }, []);
+ }, [preferencesQuery.data?.preferences.documentListState, preferencesQuery.isPending]);
// Persist.
useEffect(() => {
@@ -278,7 +273,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const state: DocumentListState = {
sortBy,
sortDirection,
- folders,
+ folders: [],
collapsedFolders: [],
showHint,
viewMode,
@@ -288,11 +283,34 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sidebarCollapsed: !sidebarOpen,
};
cachedDocumentListState = state;
- void saveDocumentListState(state);
+ const saved = preferencesQuery.data?.preferences.documentListState;
+ if (
+ saved
+ && saved.sortBy === state.sortBy
+ && saved.sortDirection === state.sortDirection
+ && (saved.showHint ?? true) === state.showHint
+ && normalizeViewMode(saved.viewMode) === state.viewMode
+ && (saved.iconSize ?? DEFAULT_STATE.iconSize) === state.iconSize
+ && (saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth) === state.sidebarWidth
+ && (saved.sidebarFilter ?? 'all') === state.sidebarFilter
+ && (saved.sidebarCollapsed ?? false) === state.sidebarCollapsed
+ ) {
+ return;
+ }
+ if (preferenceWriteTimer.current) clearTimeout(preferenceWriteTimer.current);
+ preferenceWriteTimer.current = setTimeout(() => {
+ persistPreferences({ documentListState: state });
+ preferenceWriteTimer.current = null;
+ }, 250);
+ return () => {
+ if (preferenceWriteTimer.current) {
+ clearTimeout(preferenceWriteTimer.current);
+ preferenceWriteTimer.current = null;
+ }
+ };
}, [
sortBy,
sortDirection,
- folders,
showHint,
viewMode,
iconSize,
@@ -300,6 +318,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sidebarFilter,
sidebarOpen,
isInitialized,
+ persistPreferences,
+ preferencesQuery.data?.preferences.documentListState,
]);
// Mobile drawer should never auto-open from persisted desktop state.
@@ -321,28 +341,26 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'),
[rawDocuments],
);
- const recentlyOpenedById = useLiveQuery, Record>(
- async () => {
- try {
- return await getDocumentRecentlyOpenedMap();
- } catch (err) {
- console.warn('Failed to load recently opened cache metadata:', err);
- return {};
- }
- },
- [rawDocumentIdsKey],
- {},
- );
-
const allDocuments: DocumentListDocument[] = useMemo(
() =>
rawDocuments.map((doc) => ({
...doc,
- recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0,
+ recentlyOpenedAt: doc.recentlyOpenedAt ?? 0,
})),
- [rawDocuments, recentlyOpenedById],
+ [rawDocuments],
);
+ useEffect(() => {
+ const serverFolders = folderState.query.data ?? [];
+ setFolders(serverFolders.map((folder) => ({
+ id: folder.id,
+ name: folder.name,
+ documents: rawDocuments
+ .filter((doc) => doc.folderId === folder.id)
+ .map((doc) => ({ ...doc, folderId: folder.id })),
+ })));
+ }, [folderState.query.data, rawDocumentIdsKey, rawDocuments]);
+
const allDocumentsById = useMemo(() => {
const map = new Map();
for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc);
@@ -473,10 +491,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
return { ...f, documents: [...f.documents, ...newDocs] };
}),
);
+ folderState.move.mutate({ documentIds: item.docs.map((doc) => doc.id), folderId });
setSidebarFilter(`folder:${folderId}`);
selection.clear();
},
- [selection],
+ [folderState.move, selection],
);
const handleMergeIntoFolder = useCallback(
@@ -491,50 +510,74 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[],
);
- const createFolderFromPending = useCallback(() => {
+ const createFolderFromPending = useCallback(async () => {
if (!pendingMerge) return;
const name =
newFolderName.trim() ||
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
- const folderId = `folder-${Date.now()}`;
- setFolders((prev) => [
- ...prev,
- {
- id: folderId,
- name,
- documents: [
- ...pendingMerge.sources.map((d) => ({ ...d, folderId })),
- { ...pendingMerge.target, folderId },
- ],
- },
- ]);
+ const documentIds = [...pendingMerge.sources, pendingMerge.target].map((doc) => doc.id);
+ const folderId = crypto.randomUUID();
setPendingMerge(null);
setNewFolderName('');
setShowHint(false);
setSidebarFilter(`folder:${folderId}`);
selection.clear();
- }, [pendingMerge, newFolderName, selection]);
+ const { folder } = await folderState.create.mutateAsync({
+ id: folderId,
+ name,
+ documentIds,
+ });
+ setSidebarFilter(`folder:${folder.id}`);
+ }, [folderState.create, pendingMerge, newFolderName, selection]);
+
+ const handleDismissHint = useCallback(() => {
+ setShowHint(false);
+ persistPreferences({
+ documentListState: {
+ sortBy,
+ sortDirection,
+ folders: [],
+ collapsedFolders: [],
+ showHint: false,
+ viewMode,
+ iconSize,
+ sidebarWidth,
+ sidebarFilter,
+ sidebarCollapsed: !sidebarOpen,
+ },
+ });
+ }, [
+ iconSize,
+ persistPreferences,
+ sidebarFilter,
+ sidebarOpen,
+ sidebarWidth,
+ sortBy,
+ sortDirection,
+ viewMode,
+ ]);
const createManualFolder = useCallback(() => {
const name = newFolderName.trim() || `New Folder`;
- const folderId = `folder-${Date.now()}`;
- setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
+ folderState.create.mutate({ name });
setNewFolderName('');
setManualFolderPrompt(false);
- setSidebarFilter(`folder:${folderId}`);
- }, [newFolderName]);
+ setSidebarFilter('all');
+ }, [folderState.create, newFolderName]);
const handleDeleteFolder = useCallback((folderId: string) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
+ folderState.remove.mutate(folderId);
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
- }, [sidebarFilter]);
+ }, [folderState.remove, sidebarFilter]);
const handleClearFolders = useCallback(() => {
setFolders([]);
+ folderState.clear.mutate();
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
setClearFoldersPrompt(false);
selection.clear();
- }, [selection, sidebarFilter]);
+ }, [folderState.clear, selection, sidebarFilter]);
// Status bar summary.
const summary = useMemo(() => {
@@ -666,7 +709,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
Drag files onto each other to make folders. Drop into the sidebar to move.
setShowHint(false)}
+ onClick={handleDismissHint}
size="xs"
className="h-6 w-6"
aria-label="Dismiss hint"
diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx
index aea8b53..757563a 100644
--- a/src/components/doclist/DocumentPreview.tsx
+++ b/src/components/doclist/DocumentPreview.tsx
@@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
if (status.kind === 'ready') {
const primedUrl = await primeDocumentPreviewCache(
doc.id,
- Number(doc.lastModified),
+ status.previewVersion || Number(doc.lastModified),
previewKey,
{ signal: controller.signal },
).catch(() => null);
diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx
deleted file mode 100644
index 2efa278..0000000
--- a/src/components/documents/DexieMigrationModal.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useState } from 'react';
-import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
-import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
-import { useDocuments } from '@/contexts/DocumentContext';
-import type { BaseDocument } from '@/types/documents';
-import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
-import { Button, ModalFrame, ModalTitle } from '@/components/ui';
-
-type DexieMigrationModalProps = {
- isOpen: boolean;
- localCount: number;
- missingCount: number;
- onComplete: () => void;
-};
-
-export function DexieMigrationModal({
- isOpen,
- localCount,
- missingCount,
- onComplete,
-}: DexieMigrationModalProps) {
- const { refreshDocuments } = useDocuments();
- const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
- const [isUploading, setIsUploading] = useState(false);
- const [progress, setProgress] = useState(0);
- const [status, setStatus] = useState('');
-
- const closeDisabled = isUploading;
-
- useEffect(() => {
- setDisplayMissingCount(missingCount);
- }, [missingCount, isOpen]);
-
- const loadLocalDexieDocs = useCallback(async (): Promise<{
- docs: BaseDocument[];
- pdfById: Map;
- epubById: Map;
- htmlById: Map;
- }> => {
- const [pdfs, epubs, htmls] = await Promise.all([getAllPdfDocuments(), getAllEpubDocuments(), getAllHtmlDocuments()]);
- const docs: BaseDocument[] = [
- ...pdfs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'pdf' as const })),
- ...epubs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'epub' as const })),
- ...htmls.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'html' as const })),
- ];
- const pdfById = new Map(pdfs.map((d) => [d.id, d] as const));
- const epubById = new Map(epubs.map((d) => [d.id, d] as const));
- const htmlById = new Map(htmls.map((d) => [d.id, d] as const));
- return { docs, pdfById, epubById, htmlById };
- }, []);
-
- const title = 'Upload your local documents?';
-
- const handleSkip = useCallback(async () => {
- await updateAppConfig({ documentsMigrationPrompted: true });
- onComplete();
- }, [onComplete]);
-
- const handleUpload = useCallback(async () => {
- setIsUploading(true);
- setProgress(0);
- setStatus('Preparing upload...');
-
- try {
- const { docs, pdfById, epubById, htmlById } = await loadLocalDexieDocs();
-
- const serverDocs = await listDocuments().catch(() => null);
- const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
- const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
- setDisplayMissingCount(toUpload.length);
-
- const encoder = new TextEncoder();
- for (let i = 0; i < toUpload.length; i++) {
- const doc = toUpload[i];
- setStatus(`Uploading ${i + 1}/${toUpload.length}: ${doc.name}`);
- setProgress((i / Math.max(1, toUpload.length)) * 100);
-
- // Pull raw data from Dexie for this doc
- if (doc.type === 'pdf') {
- const full = pdfById.get(doc.id) ?? null;
- if (!full) continue;
- const bytes = full.data.slice(0);
- const file = new File([full.data], full.name, {
- type: mimeTypeForDoc(doc),
- lastModified: full.lastModified,
- });
- const uploaded = await uploadDocuments([file]);
- const stored = uploaded[0] ?? null;
- if (stored) {
- await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
- }
- } else if (doc.type === 'epub') {
- const full = epubById.get(doc.id) ?? null;
- if (!full) continue;
- const bytes = full.data.slice(0);
- const file = new File([full.data], full.name, {
- type: mimeTypeForDoc(doc),
- lastModified: full.lastModified,
- });
- const uploaded = await uploadDocuments([file]);
- const stored = uploaded[0] ?? null;
- if (stored) {
- await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
- }
- } else {
- const full = htmlById.get(doc.id) ?? null;
- if (!full) continue;
- const bytes = encoder.encode(full.data).buffer;
- const file = new File([full.data], full.name, {
- type: mimeTypeForDoc(doc),
- lastModified: full.lastModified,
- });
- const uploaded = await uploadDocuments([file]);
- const stored = uploaded[0] ?? null;
- if (stored) {
- await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
- }
- }
- }
-
- setProgress(100);
- setStatus('Refreshing...');
- await refreshDocuments();
- await updateAppConfig({ documentsMigrationPrompted: true });
- onComplete();
- } catch (err) {
- console.error('Dexie migration upload failed:', err);
- setStatus('Upload failed. You can retry or skip.');
- } finally {
- setIsUploading(false);
- }
- }, [loadLocalDexieDocs, onComplete, refreshDocuments]);
-
- return (
- {
- if (!closeDisabled) onComplete();
- }}
- panelTestId="migration-modal"
- className="z-[80]"
- >
- {title}
-
-
- Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
- {displayMissingCount > 0 ? (
- <> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.>
- ) : null}
- {' '}This app now stores documents on the server and keeps a local cache for speed.
-
- {isUploading && (
-
- )}
- {!isUploading && status ?
{status}
: null}
-
-
-
-
- Skip
-
-
- {isUploading ? 'Uploading…' : 'Upload'}
-
-
-
- );
-}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index a397c0f..835deeb 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -1,27 +1,18 @@
'use client';
-import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
-import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
-import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
-import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
+import { createContext, useContext, useMemo, type ReactNode } from 'react';
+import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config';
import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
-import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
-import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
-import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
-import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
+import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders';
-import toast from 'react-hot-toast';
+import { useUserPreferences } from '@/hooks/useUserPreferences';
+import type { SyncedPreferencesPatch } from '@/types/user-state';
+
export type { ViewType } from '@/types/config';
-/** Configuration values for the application */
-
-/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
- apiKey: string;
- baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
@@ -40,7 +31,6 @@ interface ConfigContextType {
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
- updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise;
updateConfigKey: (key: K, value: AppConfigValues[K]) => Promise;
isLoading: boolean;
isDBReady: boolean;
@@ -54,456 +44,82 @@ interface ConfigContextType {
const ConfigContext = createContext(undefined);
-/**
- * Provider component for application configuration
- * Manages global configuration state and persistence
- * @param {Object} props - Component props
- * @param {ReactNode} props.children - Child components to be wrapped by the provider
- */
export function ConfigProvider({ children }: { children: ReactNode }) {
- const [isLoading, setIsLoading] = useState(true);
- const [isDBReady, setIsDBReady] = useState(false);
- const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
- const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
- const showAllProviderModels = useFeatureFlag('showAllProviderModels');
- const didRunStartupMigrations = useRef(false);
- const didAttemptInitialPreferenceSeedForSession = useRef(null);
- const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []);
+ const { data: session, isPending: isSessionPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
- const { data: sessionData, isPending: isSessionPending } = useAuthSession();
- const sessionKey = sessionData?.user?.id ?? 'no-session';
- // The instance/admin default provider. An empty user providerRef "inherits"
- // this, resolved (admin slug -> concrete provider) where the value is used.
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
+ const { query, mutation } = useUserPreferences(sessionId, !isSessionPending);
- const queueSyncedPreferencePatch = useCallback((patch: Partial) => {
- if (sessionKey === 'no-session') return;
+ const config = useMemo(() => ({
+ ...APP_CONFIG_DEFAULTS,
+ ...(query.data?.preferences ?? {}),
+ }), [query.data?.preferences]);
- const syncedPatch: SyncedPreferencesPatch = {};
- for (const key of SYNCED_PREFERENCE_KEYS) {
- if (!(key in patch)) continue;
- const value = patch[key];
- if (value === undefined) continue;
- (syncedPatch as Record)[key] = value;
- }
- if (Object.keys(syncedPatch).length === 0) return;
- scheduleUserPreferencesSync(syncedPatch, sessionKey);
- }, [sessionKey]);
+ const effectiveProvider = useMemo(() => resolveProviderDefaults({
+ providerRef: config.providerRef,
+ providerType: config.providerType,
+ sharedProviders,
+ fallbackProviderRef: adminDefaultProviderRef,
+ }), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]);
- // Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
- useEffect(() => {
- return () => {
- cancelPendingPreferenceSync();
- };
- }, [sessionKey]);
-
- useEffect(() => {
- const handler = (event: Event) => {
- const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
- const status = detail?.status;
- if (status === 'opened') {
- toast.dismiss('dexie-blocked');
- return;
- }
- if (status === 'blocked' || status === 'stalled') {
- const message =
- 'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.';
- toast.error(message, { id: 'dexie-blocked', duration: Infinity });
- }
- };
-
- window.addEventListener('openreader:dexie', handler as EventListener);
- return () => {
- window.removeEventListener('openreader:dexie', handler as EventListener);
- };
- }, []);
-
- useEffect(() => {
- const initializeDB = async () => {
- try {
- setIsLoading(true);
- await initDB();
- setIsDBReady(true);
- } catch (error) {
- console.error('Error initializing Dexie:', error);
- } finally {
- setIsLoading(false);
- }
- };
-
- initializeDB();
- }, []);
-
- useEffect(() => {
- if (!isDBReady) return;
- if (didRunStartupMigrations.current) return;
- didRunStartupMigrations.current = true;
-
- const run = async () => {
- try {
- await migrateLegacyDexieDocumentIdsToSha();
- } catch (error) {
- console.warn('Startup migrations failed:', error);
- }
- };
-
- void run();
- }, [isDBReady]);
-
- const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
- if (!isDBReady) return;
- try {
- 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]);
-
- useEffect(() => {
- if (!isDBReady || isSessionPending) return;
- 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, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
-
- useEffect(() => {
- if (!isDBReady) return;
- let activeController: AbortController | null = null;
- const onFocus = () => {
- 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, refreshSyncedPreferencesFromServer]);
-
- const appConfig = useLiveQuery(
- async () => {
- if (!isDBReady) return null;
- const row = await db['app-config'].get('singleton');
- return row ?? null;
- },
- [isDBReady],
- null,
- );
-
- const config: AppConfigValues | null = useMemo(() => {
- if (!appConfig) return null;
- const { id, ...rest } = appConfig;
- void id;
- return { ...APP_CONFIG_DEFAULTS, ...rest };
- }, [appConfig]);
-
- useEffect(() => {
- if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
- const resetPatch: Partial = {};
-
- // When the provider tab is hidden, clear any user-set provider config back to
- // "inherit the admin default" (empty) rather than baking in a concrete value.
- if (appConfig.apiKey !== '') resetPatch.apiKey = '';
- if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
- if (appConfig.providerRef !== '') resetPatch.providerRef = '';
- if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown';
- if (appConfig.ttsModel !== '') resetPatch.ttsModel = '';
- if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = '';
- // Keep voice selection state intact so player/Audiobook voice pickers still
- // work when the TTS providers tab is hidden. This reset is only for provider
- // configuration fields.
-
- if (Object.keys(resetPatch).length === 0) return;
-
- updateAppConfig(resetPatch).catch((error) => {
- console.warn('Failed to clear hidden TTS provider settings:', error);
- });
- queueSyncedPreferencePatch(resetPatch);
- }
- }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
-
- useEffect(() => {
- if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) {
- const resetPatch: Partial = {};
-
- if (appConfig.apiKey !== '') resetPatch.apiKey = '';
- if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
- // Built-in providers aren't selectable in restricted mode. Clear any stale
- // built-in selection (including the old 'custom-openai' default that used to
- // be baked into every config) back to "inherit the admin default" so the
- // user follows whatever shared provider the admin has configured.
- if (isBuiltInTtsProviderId(appConfig.providerRef)) {
- resetPatch.providerRef = '';
- resetPatch.providerType = 'unknown';
- resetPatch.ttsModel = '';
- resetPatch.ttsInstructions = '';
- }
-
- if (Object.keys(resetPatch).length === 0) return;
-
- updateAppConfig(resetPatch).catch((error) => {
- console.warn('Failed to enforce restricted user API key mode:', error);
- });
- queueSyncedPreferencePatch(resetPatch);
- }
- }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
-
- useEffect(() => {
- if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return;
- // Inheriting (empty providerRef): the effective model is resolved at read
- // time, so there is nothing to persist/enforce here.
- if (!appConfig.providerRef) return;
- const providerDefaults = resolveProviderDefaults({
- providerRef: appConfig.providerRef,
- providerType: appConfig.providerType,
- sharedProviders,
- fallbackProviderRef: adminDefaultProviderRef,
- });
- if (!providerDefaults.defaultModel) return;
- if (appConfig.ttsModel === providerDefaults.defaultModel) return;
- const patch: Partial = { ttsModel: providerDefaults.defaultModel };
- updateAppConfig(patch).catch((error) => {
- console.warn('Failed to enforce provider default model restriction:', error);
- });
- queueSyncedPreferencePatch(patch);
- }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
-
- useEffect(() => {
- if (!isDBReady || !appConfig || isSessionPending) return;
- if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
- didAttemptInitialPreferenceSeedForSession.current = sessionKey;
-
- const controller = new AbortController();
-
- const run = async () => {
- try {
- const remote = await getUserPreferences({ signal: controller.signal });
- if (remote?.hasStoredPreferences) return;
-
- // Seed only user-customized (non-default) values. This prevents fresh/default
- // profiles from overwriting existing server values during first-run races.
- const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
- if (Object.keys(patch).length === 0) return;
-
- 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, appConfig, isSessionPending, sessionKey]);
-
- // Destructure for convenience and to match context shape
- const {
- apiKey,
- baseUrl,
- viewType,
- voiceSpeed,
- audioPlayerSpeed,
- voice,
- skipBlank,
- epubTheme,
- headerMargin,
- footerMargin,
- leftMargin,
- rightMargin,
- providerRef,
- providerType: _persistedProviderType,
- ttsModel,
- ttsInstructions,
- savedVoices,
- segmentPreloadDepthPages,
- segmentPreloadSentenceLookahead,
- ttsSegmentMaxBlockLength,
- pdfHighlightEnabled,
- pdfWordHighlightEnabled,
- epubHighlightEnabled,
- epubWordHighlightEnabled,
- htmlHighlightEnabled,
- htmlWordHighlightEnabled,
- } = config || APP_CONFIG_DEFAULTS;
- // Resolve the effective provider for consumers. An empty stored providerRef
- // means "inherit the admin default", which we resolve here so the reader,
- // voice pickers, and settings UI all see a concrete, usable provider without
- // mutating the stored ("inherit") value.
- const effectiveProvider = useMemo(
- () => resolveProviderDefaults({
- providerRef,
- providerType: _persistedProviderType,
- sharedProviders,
- fallbackProviderRef: adminDefaultProviderRef,
- }),
- [providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef],
- );
const effectiveProviderRef = effectiveProvider.providerRef;
- const providerType = effectiveProvider.providerType;
- const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
- const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
+ const effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel;
+ const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions;
- useEffect(() => {
- if (!isDBReady || !appConfig || sharedProvidersLoading) return;
- // Only persist a resolved providerType for an explicitly chosen provider.
- // While inheriting (empty providerRef) the type stays unset in storage.
- if (!appConfig.providerRef) return;
- if (appConfig.providerType === providerType) return;
- const patch: Partial = { providerType };
- updateAppConfig(patch).catch((error) => {
- console.warn('Failed to persist resolved providerType:', error);
- });
- queueSyncedPreferencePatch(patch);
- }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
- void _persistedProviderType;
-
- useEffect(() => {
- if (!isDBReady || !appConfig || sharedProvidersLoading) return;
- // Inheriting (empty providerRef): the effective model is resolved at read
- // time; don't write a concrete model into the "inherit" state.
- if (!appConfig.providerRef) return;
- const providerDefaults = resolveProviderDefaults({
- providerRef: appConfig.providerRef,
- providerType: appConfig.providerType,
- sharedProviders,
- fallbackProviderRef: adminDefaultProviderRef,
- });
- if (!providerDefaults.defaultModel) return;
- if (appConfig.ttsModel === providerDefaults.defaultModel) return;
- // Heal stale fallback model values that were written while the provider UI
- // was disabled and shared provider context was unavailable.
- if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
-
- const patch: Partial = { ttsModel: providerDefaults.defaultModel };
- updateAppConfig(patch).catch((error) => {
- console.warn('Failed to normalize shared-provider default model:', error);
- });
- queueSyncedPreferencePatch(patch);
- }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
-
- /**
- * Updates multiple configuration values simultaneously
- * Only saves API credentials if they are explicitly set
- */
- const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
- try {
- setIsLoading(true);
- const updates: Partial = {};
- if (newConfig.apiKey !== undefined) {
- updates.apiKey = newConfig.apiKey;
- }
- if (newConfig.baseUrl !== undefined) {
- updates.baseUrl = newConfig.baseUrl;
- }
- if (newConfig.viewType !== undefined) {
- updates.viewType = newConfig.viewType;
- }
- await updateAppConfig(updates);
- queueSyncedPreferencePatch(updates);
- } catch (error) {
- console.error('Error updating config:', error);
- throw error;
- } finally {
- setIsLoading(false);
- }
+ const updatePatch = async (patch: Partial) => {
+ await mutation.mutateAsync(patch as SyncedPreferencesPatch);
};
- /**
- * Updates a single configuration value by key
- * @param {K} key - The configuration key to update
- * @param {AppConfigValues[K]} value - The new value for the configuration
- */
const updateConfigKey = async (key: K, value: AppConfigValues[K]) => {
- try {
- setIsLoading(true);
- const { storagePatch, syncPatch } = applyConfigUpdate({
- providerRef: effectiveProviderRef,
- providerType,
- ttsModel: effectiveTtsModel,
- savedVoices,
- }, key, value);
-
- await updateAppConfig(storagePatch);
- if (
- key === 'voice' ||
- key === 'providerRef' ||
- key === 'providerType' ||
- key === 'ttsModel' ||
- key === 'savedVoices' ||
- syncedPreferenceKeys.has(String(key))
- ) {
- queueSyncedPreferencePatch(syncPatch);
- }
- } catch (error) {
- console.error(`Error updating config key ${String(key)}:`, error);
- throw error;
- } finally {
- setIsLoading(false);
- }
+ const { syncPatch } = applyConfigUpdate({
+ providerRef: effectiveProviderRef,
+ providerType: effectiveProvider.providerType,
+ ttsModel: effectiveTtsModel,
+ savedVoices: config.savedVoices,
+ }, key, value);
+ await updatePatch(syncPatch);
};
+ const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading;
+
return (
{children}
);
}
-/**
- * Custom hook to consume the configuration context
- * @returns {ConfigContextType} The configuration context value
- * @throws {Error} When used outside of ConfigProvider
- */
export function useConfig() {
const context = useContext(ConfigContext);
- if (context === undefined) {
- throw new Error('useConfig must be used within a ConfigProvider');
- }
+ if (!context) throw new Error('useConfig must be used within a ConfigProvider');
return context;
}
diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx
index d96dd4d..837d46f 100644
--- a/src/contexts/DocumentContext.tsx
+++ b/src/contexts/DocumentContext.tsx
@@ -1,7 +1,7 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
-import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { BaseDocument } from '@/types/documents';
import {
deleteDocuments as deleteServerDocuments,
@@ -102,10 +102,31 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
return { pdfDocs, epubDocs, htmlDocs };
}, [docs]);
+ const uploadMutation = useMutation({
+ mutationFn: (files: File[]) => uploadServerDocuments(files),
+ onSuccess: (stored) => {
+ queryClient.setQueryData(documentsQueryKey, (previous) =>
+ mergeStoredDocuments(previous, stored),
+ );
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
+ });
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => deleteServerDocuments({ ids: [id] }),
+ onMutate: async (id) => {
+ await queryClient.cancelQueries({ queryKey: documentsQueryKey });
+ const previous = queryClient.getQueryData(documentsQueryKey);
+ queryClient.setQueryData(documentsQueryKey, (rows = []) => rows.filter((doc) => doc.id !== id));
+ return { previous };
+ },
+ onError: (_error, _id, context) => queryClient.setQueryData(documentsQueryKey, context?.previous),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
+ });
+
const uploadDocuments = useCallback(async (files: File[]): Promise => {
if (files.length === 0) return [];
- const stored = await uploadServerDocuments(files);
+ const stored = await uploadMutation.mutateAsync(files);
await Promise.allSettled(
stored.map(async (document, index) => {
const file = files[index];
@@ -134,20 +155,13 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
}),
);
- queryClient.setQueryData(documentsQueryKey, (previous) =>
- mergeStoredDocuments(previous, stored),
- );
-
return stored;
- }, [documentsQueryKey, queryClient]);
+ }, [uploadMutation]);
const deleteDocument = useCallback(async (id: string) => {
- await deleteServerDocuments({ ids: [id] });
+ await deleteMutation.mutateAsync(id);
await evictCachedDocument(id);
- queryClient.setQueryData(documentsQueryKey, (previous = []) =>
- previous.filter((document) => document.id !== id),
- );
- }, [documentsQueryKey, queryClient]);
+ }, [deleteMutation]);
return (
(null);
-type MigrationPromptState = {
- shouldPrompt: boolean;
- localCount: number;
- missingCount: number;
-};
-
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
documents: 0,
audiobooks: 0,
preferences: 0,
progress: 0,
documentSettings: 0,
+ folders: 0,
+ onboarding: 0,
};
function toClaimableCounts(value: unknown): ClaimableCounts {
@@ -41,6 +36,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
+ folders: Number(rec.folders ?? 0),
+ onboarding: Number(rec.onboarding ?? 0),
};
}
@@ -53,71 +50,22 @@ async function fetchClaimableCounts(): Promise {
return toClaimableCounts(data);
}
-async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise {
- const cfg = await getAppConfig();
- if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) {
- return { shouldPrompt: false, localCount: 0, missingCount: 0 };
- }
-
- const [pdfs, epubs, htmls] = await Promise.all([
- getAllPdfDocuments(),
- getAllEpubDocuments(),
- getAllHtmlDocuments(),
- ]);
- const localDocs = [
- ...pdfs.map((d) => d.id),
- ...epubs.map((d) => d.id),
- ...htmls.map((d) => d.id),
- ];
- const localCount = localDocs.length;
- if (localCount === 0) {
- return { shouldPrompt: false, localCount: 0, missingCount: 0 };
- }
-
- const serverDocs = await listDocuments().catch(() => null);
- if (!serverDocs) {
- return { shouldPrompt: true, localCount, missingCount: localCount };
- }
-
- const serverIds = new Set(serverDocs.map((d) => d.id));
- const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
- return {
- shouldPrompt: missingCount > 0,
- localCount,
- missingCount,
- };
-}
-
-type LocalOnboardingSnapshot = {
- privacyAccepted: boolean;
- firstVisitSettingsOpened: boolean;
-};
-
-async function readLocalOnboardingSnapshot(): Promise {
- const appConfig = await getAppConfig();
- const row = appConfig as Record | null;
- const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
- const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
-
- return {
- privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
- firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
- };
-}
-
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
const { data: session, isPending: isSessionPending } = useAuthSession();
const runtimeConfig = useRuntimeConfig();
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
const userId = user?.id ?? null;
const isAnonymous = Boolean(user?.isAnonymous);
-
- const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null);
- const [claimableCounts, setClaimableCounts] = useState(EMPTY_CLAIM_COUNTS);
- const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
- localCount: 0,
- missingCount: 0,
+ const claimCountsQuery = useQuery({
+ queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
+ queryFn: fetchClaimableCounts,
+ enabled: Boolean(userId && !isAnonymous),
});
+ const refetchClaimCounts = claimCountsQuery.refetch;
+
+ const { query: onboardingQuery } = useOnboardingState();
+ const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null);
+ const [claimableCounts, setClaimableCounts] = useState(EMPTY_CLAIM_COUNTS);
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
const pendingChangelogOpenRef = useRef(false);
@@ -135,9 +83,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
);
const runOnceFlow = useCallback(async () => {
- const local = await readLocalOnboardingSnapshot();
+ // Wait until the onboarding state has actually loaded before deciding whether
+ // to show the privacy modal. Otherwise the not-yet-loaded query (data === undefined)
+ // reads as "not accepted", the modal flashes on first paint, then closes once the
+ // real state arrives.
+ const onboardingData = onboardingQuery.data;
+ if (onboardingData === undefined) {
+ return;
+ }
+
const privacyRequired = true;
- const privacyAccepted = !privacyRequired || local.privacyAccepted;
+ const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs);
const isClaimEligible = Boolean(
userId
@@ -149,26 +105,25 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
let claimHasData = false;
if (isClaimEligible) {
- claimCounts = await fetchClaimableCounts();
+ claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS;
const total = claimCounts.documents
+ claimCounts.audiobooks
+ claimCounts.preferences
+ claimCounts.progress
- + claimCounts.documentSettings;
+ + claimCounts.documentSettings
+ + claimCounts.folders
+ + claimCounts.onboarding;
claimHasData = total > 0;
if (!claimHasData && userId) {
claimDismissedUsersRef.current.add(userId);
}
}
- const migrationState = await getMigrationPromptState(privacyAccepted);
-
const nextStep = resolveNextOnboardingStep({
privacyRequired,
privacyAccepted,
claimEligible: isClaimEligible,
claimHasData,
- migrationRequired: migrationState.shouldPrompt,
changelogPending: pendingChangelogOpenRef.current,
});
@@ -183,26 +138,13 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
return;
}
- if (nextStep === 'migration') {
- setMigrationCounts({
- localCount: migrationState.localCount,
- missingCount: migrationState.missingCount,
- });
- setActiveBlockingModal('migration');
- return;
- }
-
setActiveBlockingModal(null);
- if (!local.firstVisitSettingsOpened) {
- await setFirstVisit(true);
- }
-
if (nextStep === 'changelog') {
pendingChangelogOpenRef.current = false;
setChangelogOpenSignal((value) => value + 1);
}
- }, [isAnonymous, userId]);
+ }, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]);
runOnceFlowRef.current = runOnceFlow;
@@ -214,11 +156,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
void runFlow();
}, [runFlow, userId]);
- const handleMigrationComplete = useCallback(() => {
- setActiveBlockingModal(null);
- void runFlow();
- }, [runFlow]);
-
const handlePrivacyAccepted = useCallback(() => {
setActiveBlockingModal(null);
void runFlow();
@@ -226,7 +163,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void runFlow();
- }, [isAnonymous, runFlow, userId]);
+ }, [isAnonymous, onboardingQuery.data, runFlow, userId]);
useEffect(() => {
const onPrivacyAccepted = () => {
@@ -273,12 +210,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
onDismiss={handleClaimComplete}
onClaimed={handleClaimComplete}
/>
-
);
}
diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx
index 30a8e3d..d99203a 100644
--- a/src/contexts/RuntimeConfigContext.tsx
+++ b/src/contexts/RuntimeConfigContext.tsx
@@ -94,7 +94,7 @@ export function useFeatureFlag(key: K): RuntimeCo
/**
* Synchronous accessor for modules that are loaded before the React tree
- * mounts (e.g. Dexie initialization, config defaults). Falls back to the
+ * mounts (for example config defaults). Falls back to the
* built-in defaults during SSR.
*/
export function readRuntimeConfigSync(): RuntimeConfig {
diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx
index e8431f4..a335fcf 100644
--- a/src/contexts/TTSContext.tsx
+++ b/src/contexts/TTSContext.tsx
@@ -33,8 +33,7 @@ import { useConfig } from '@/contexts/ConfigContext';
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext';
-import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
-import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
+import { useDocumentProgress } from '@/hooks/useDocumentProgress';
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import {
@@ -82,6 +81,7 @@ import type {
TTSSegmentManifestItem,
} from '@/types/client';
import { isStableEpubLocator } from '@/types/client';
+import { clearCachedAudioObjectUrls, getCachedAudioUrl } from '@/lib/client/cache/audio';
/**
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
@@ -420,8 +420,6 @@ const TTSContext = createContext(undefined);
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
// Configuration context consumption
const {
- apiKey: openApiKey,
- baseUrl: openApiBaseUrl,
isLoading: configIsLoading,
voiceSpeed,
audioPlayerSpeed,
@@ -444,8 +442,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Audio and voice management hooks
const audioContext = useAudioContext();
const { availableVoices, fetchVoices } = useVoiceManagement(
- openApiKey,
- openApiBaseUrl,
configProviderRef,
configProviderType,
configTTSModel,
@@ -466,6 +462,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (Array.isArray(id)) return id[0];
return '';
}, [id]);
+ const { query: progressQuery, schedule: scheduleProgress } = useDocumentProgress(documentId || undefined);
const currentReaderType: ReaderType = useMemo(() => {
if (pathname.startsWith('/epub/')) return 'epub';
@@ -729,6 +726,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
useEffect(() => () => {
clearWarmAudioCache();
+ clearCachedAudioObjectUrls();
}, [clearWarmAudioCache]);
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
@@ -1619,7 +1617,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setTTSModel(configTTSModel);
setTTSInstructions(configTTSInstructions);
}
- }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
+ }, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
const preloadGenerationSignatureRef = useRef('');
useEffect(() => {
@@ -1766,12 +1764,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
- 'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
- if (openApiBaseUrl) {
- reqHeaders['x-openai-base-url'] = openApiBaseUrl;
- }
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
@@ -1914,8 +1908,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
ttsInstructions,
resolvedLanguage,
- openApiKey,
- openApiBaseUrl,
configProviderRef,
configProviderType,
providerModelPolicy.supportsInstructions,
@@ -2044,7 +2036,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
setCurrentWordIndex(null);
}
- const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
+ const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
+ const audioUrl = await getCachedAudioUrl({
+ audioKey: playbackSource.manifest.segmentId,
+ version: playbackSource.manifest.durationMs,
+ primaryUrl: sourceUrl,
+ fallbackUrl: playbackSource.fallbackUrl,
+ }).catch(() => sourceUrl);
// Force unload any previous Howl instance to free up resources
if (activeHowl) {
@@ -2610,12 +2608,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.add(controller);
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
- 'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
- if (openApiBaseUrl) {
- reqHeaders['x-openai-base-url'] = openApiBaseUrl;
- }
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
initialDelay: 300,
@@ -2903,12 +2897,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.add(controller);
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
- 'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
- if (openApiBaseUrl) {
- reqHeaders['x-openai-base-url'] = openApiBaseUrl;
- }
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
@@ -3026,8 +3016,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
configProviderRef,
configProviderType,
ttsModel,
- openApiKey,
- openApiBaseUrl,
providerModelPolicy.supportsInstructions,
ttsInstructions,
resolvedLanguage,
@@ -3397,13 +3385,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
skipBackward,
});
- // Load last location on mount for EPUB/PDF/HTML.
- // Prefer server-backed progress when available, then fall back to local Dexie.
+ // Load the server-backed last location for EPUB/PDF/HTML.
useEffect(() => {
- if (!id) return;
-
- let cancelled = false;
- const docId = id as string;
+ if (!id || !progressQuery.data?.location) return;
const applyLocation = (lastLocation: string) => {
if (isEPUB && locationChangeHandlerRef.current) {
@@ -3454,35 +3438,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
};
- const load = async () => {
- try {
- const local = await getLastDocumentLocation(docId);
- if (!cancelled && local) {
- applyLocation(local);
- }
- } catch (error) {
- console.warn('Error loading local last location:', error);
- }
-
- try {
- const remote = await getDocumentProgress(docId);
- if (!cancelled && remote?.location) {
- await setLastDocumentLocation(docId, remote.location).catch((error) => {
- console.warn('Error caching remote location locally:', error);
- });
- applyLocation(remote.location);
- }
- } catch (error) {
- console.warn('Error loading remote progress:', error);
- }
- };
-
- load();
-
- return () => {
- cancelled = true;
- };
- }, [id, isEPUB, currentReaderType]);
+ applyLocation(progressQuery.data.location);
+ }, [id, isEPUB, currentReaderType, progressQuery.data?.location]);
// Save current position periodically for non-EPUB readers.
useEffect(() => {
@@ -3491,10 +3448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
: `${currDocPageNumber}:${currentIndex}`;
const timeoutId = setTimeout(() => {
- setLastDocumentLocation(id as string, location).catch(error => {
- console.warn('Error saving non-EPUB location:', error);
- });
- scheduleDocumentProgressSync({
+ scheduleProgress({
documentId: id as string,
readerType: currentReaderType,
location,
@@ -3503,7 +3457,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return () => clearTimeout(timeoutId);
}
- }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
+ }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, scheduleProgress]);
/**
* Renders the TTS context provider with its children
diff --git a/src/hooks/audio/useVoiceManagement.ts b/src/hooks/audio/useVoiceManagement.ts
index b7c2745..de2e20f 100644
--- a/src/hooks/audio/useVoiceManagement.ts
+++ b/src/hooks/audio/useVoiceManagement.ts
@@ -7,16 +7,12 @@ import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'
/**
* Custom hook for managing TTS voices
- * @param apiKey OpenAI API key
- * @param baseUrl OpenAI API base URL
* @param providerRef TTS provider routing reference (built-in id or shared slug)
* @param providerType Resolved provider type for capability/default logic
* @param ttsModel TTS model name
* @returns Object containing available voices and fetch function
*/
export function useVoiceManagement(
- apiKey: string | undefined,
- baseUrl: string | undefined,
providerRef: string | undefined,
providerType: TtsProviderType | undefined,
ttsModel: string | undefined
@@ -29,8 +25,6 @@ export function useVoiceManagement(
try {
console.log('Fetching voices...');
const data = await getVoices({
- 'x-openai-key': apiKey || '',
- 'x-openai-base-url': baseUrl || '',
'x-tts-provider': providerRef || 'openai',
'x-tts-model': ttsModel || 'tts-1',
'Content-Type': 'application/json',
@@ -56,7 +50,7 @@ export function useVoiceManagement(
model: ttsModel || 'tts-1',
}).defaultVoices);
}
- }, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
+ }, [providerRef, providerType, ttsModel]);
return { availableVoices, fetchVoices };
}
diff --git a/src/hooks/epub/useEPUBAudiobook.ts b/src/hooks/epub/useEPUBAudiobook.ts
index ded81c1..bf93786 100644
--- a/src/hooks/epub/useEPUBAudiobook.ts
+++ b/src/hooks/epub/useEPUBAudiobook.ts
@@ -35,8 +35,6 @@ export function filterNonEmptySpineTextEntries(entri
type UseEpubAudiobookParams = {
bookRef: RefObject;
tocRef: RefObject;
- apiKey: string;
- baseUrl: string;
providerRef: string;
};
@@ -61,8 +59,6 @@ type UseEpubAudiobookResult = {
export function useEPUBAudiobook({
bookRef,
tocRef,
- apiKey,
- baseUrl,
providerRef,
}: UseEpubAudiobookParams): UseEpubAudiobookResult {
const loadSpineSection = useCallback(async (href: string) => {
@@ -131,8 +127,6 @@ export function useEPUBAudiobook({
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@@ -145,7 +139,7 @@ export function useEPUBAudiobook({
console.error('Error creating audiobook:', error);
throw error;
}
- }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
+ }, [audiobookAdapter, providerRef]);
const regenerateChapter = useCallback(async (
chapterIndex: number,
@@ -161,8 +155,6 @@ export function useEPUBAudiobook({
bookId,
format,
signal,
- apiKey,
- baseUrl,
defaultProvider: providerRef,
settings,
});
@@ -173,7 +165,7 @@ export function useEPUBAudiobook({
console.error('Error regenerating chapter:', error);
throw error;
}
- }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
+ }, [audiobookAdapter, providerRef]);
return {
createFullAudioBook,
diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts
deleted file mode 100644
index 989d009..0000000
--- a/src/hooks/epub/useEPUBDocuments.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-'use client';
-
-import { useCallback } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
-import { db } from '@/lib/client/dexie';
-import type { EPUBDocument } from '@/types/documents';
-import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
-
-export function useEPUBDocuments() {
- const documents = useLiveQuery(
- () => db['epub-documents'].toArray(),
- [],
- undefined,
- );
-
- const isLoading = documents === undefined;
-
- const addDocument = useCallback(async (file: File): Promise => {
- const arrayBuffer = await file.arrayBuffer();
- const id = await sha256HexFromArrayBuffer(arrayBuffer);
-
- console.log('Original file size:', file.size);
- console.log('ArrayBuffer size:', arrayBuffer.byteLength);
-
- const newDoc: EPUBDocument = {
- id,
- type: 'epub',
- name: file.name,
- size: file.size,
- lastModified: file.lastModified,
- data: arrayBuffer,
- };
-
- await db['epub-documents'].put(newDoc);
- return id;
- }, []);
-
- const removeDocument = useCallback(async (id: string): Promise => {
- await db['epub-documents'].delete(id);
- }, []);
-
- const clearDocuments = useCallback(async (): Promise => {
- await db['epub-documents'].clear();
- }, []);
-
- return {
- documents: documents ?? [],
- isLoading,
- addDocument,
- removeDocument,
- clearDocuments,
- };
-}
diff --git a/src/hooks/epub/useEPUBLocationController.ts b/src/hooks/epub/useEPUBLocationController.ts
index 726122a..f20374a 100644
--- a/src/hooks/epub/useEPUBLocationController.ts
+++ b/src/hooks/epub/useEPUBLocationController.ts
@@ -3,33 +3,14 @@
import { useCallback, type MutableRefObject, type RefObject } from 'react';
import type { Book, Rendition } from 'epubjs';
-import { setLastDocumentLocation } from '@/lib/client/dexie';
-import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
+import { useDocumentProgress } from '@/hooks/useDocumentProgress';
-type EpubLocation = string | number;
-
-export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
- return location === 'next' || location === 'prev';
-}
-
-export function shouldNavigateToDifferentCfi(
- location: EpubLocation,
- currentStartCfi: string | undefined,
-): location is string {
- return (
- typeof location === 'string'
- && !isDirectionalEpubLocation(location)
- && !!currentStartCfi
- && location !== currentStartCfi
- );
-}
-
-export function shouldPersistEpubLocation(
- documentId: string | undefined,
- previousLocation: EpubLocation,
-): documentId is string {
- return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
-}
+import {
+ isDirectionalEpubLocation,
+ shouldNavigateToDifferentCfi,
+ shouldPersistEpubLocation,
+ type EpubLocation,
+} from '@/lib/client/epub/location-controller';
type UseEpubLocationControllerParams = {
documentId?: string;
@@ -54,6 +35,7 @@ export function useEPUBLocationController({
renditionRef,
locationRef,
}: UseEpubLocationControllerParams): (location: EpubLocation) => void {
+ const { schedule: scheduleProgress } = useDocumentProgress(documentId);
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
const book = bookRef.current;
const rendition = renditionRef.current;
@@ -133,10 +115,9 @@ export function useEPUBLocationController({
return;
}
- // Save the location to IndexedDB if not initial
+ // Save the server-backed location after the first real rendition update.
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
- setLastDocumentLocation(documentId, location.toString());
- scheduleDocumentProgressSync({
+ scheduleProgress({
documentId,
readerType: 'epub',
location: location.toString(),
@@ -160,8 +141,11 @@ export function useEPUBLocationController({
safeRenditionNavigate,
setIsEpub,
shouldPauseRef,
+ scheduleProgress,
skipToLocation,
]);
return handleLocationChanged;
}
+
+export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation };
diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts
deleted file mode 100644
index 2714c05..0000000
--- a/src/hooks/html/useHTMLDocuments.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-'use client';
-
-import { useCallback } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
-import { db } from '@/lib/client/dexie';
-import type { HTMLDocument } from '@/types/documents';
-import { sha256HexFromString } from '@/lib/client/sha256';
-
-export function useHTMLDocuments() {
- const documents = useLiveQuery(
- () => db['html-documents'].toArray(),
- [],
- undefined,
- );
-
- const isLoading = documents === undefined;
-
- const addDocument = useCallback(async (file: File): Promise => {
- const buffer = await file.arrayBuffer();
- const bytes = new Uint8Array(buffer);
- const content = new TextDecoder().decode(bytes);
- const id = await sha256HexFromString(content);
-
- const newDoc: HTMLDocument = {
- id,
- type: 'html',
- name: file.name,
- size: file.size,
- lastModified: file.lastModified,
- data: content,
- };
-
- await db['html-documents'].put(newDoc);
- return id;
- }, []);
-
- const removeDocument = useCallback(async (id: string): Promise => {
- await db['html-documents'].delete(id);
- }, []);
-
- const clearDocuments = useCallback(async (): Promise => {
- await db['html-documents'].clear();
- }, []);
-
- return {
- documents: documents ?? [],
- isLoading,
- addDocument,
- removeDocument,
- clearDocuments,
- };
-}
diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts
deleted file mode 100644
index 44f0668..0000000
--- a/src/hooks/pdf/usePDFDocuments.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-'use client';
-
-import { useCallback } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
-import { db } from '@/lib/client/dexie';
-import type { PDFDocument } from '@/types/documents';
-import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
-
-export function usePDFDocuments() {
- const documents = useLiveQuery(
- () => db['pdf-documents'].toArray(),
- [],
- undefined,
- );
-
- const isLoading = documents === undefined;
-
- const addDocument = useCallback(async (file: File): Promise => {
- const arrayBuffer = await file.arrayBuffer();
- const id = await sha256HexFromArrayBuffer(arrayBuffer);
-
- const newDoc: PDFDocument = {
- id,
- type: 'pdf',
- name: file.name,
- size: file.size,
- lastModified: file.lastModified,
- data: arrayBuffer,
- };
-
- await db['pdf-documents'].put(newDoc);
- return id;
- }, []);
-
- const removeDocument = useCallback(async (id: string): Promise => {
- await db['pdf-documents'].delete(id);
- }, []);
-
- const clearDocuments = useCallback(async (): Promise => {
- await db['pdf-documents'].clear();
- }, []);
-
- return {
- documents: documents ?? [],
- isLoading,
- addDocument,
- removeDocument,
- clearDocuments,
- };
-}
diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts
index c8ca980..3b1d8ed 100644
--- a/src/hooks/useDocumentLanguage.ts
+++ b/src/hooks/useDocumentLanguage.ts
@@ -1,52 +1,31 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback } from 'react';
-import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
+import { useDocumentSettings } from '@/hooks/useDocumentSettings';
export function useDocumentLanguage(documentId: string | undefined): {
language: string;
updateLanguage: (language: string) => Promise;
} {
- const [settings, setSettings] = useState(DEFAULT_DOCUMENT_SETTINGS);
-
- useEffect(() => {
- setSettings(DEFAULT_DOCUMENT_SETTINGS);
- if (!documentId) return;
-
- const controller = new AbortController();
- void getDocumentSettings(documentId, { signal: controller.signal })
- .then((response) => {
- setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
- })
- .catch((error) => {
- if (error instanceof DOMException && error.name === 'AbortError') return;
- console.warn('Failed to load document language, using automatic detection:', error);
- });
-
- return () => controller.abort();
- }, [documentId]);
+ const { query, mutation } = useDocumentSettings(documentId);
+ const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.settings);
const updateLanguage = useCallback(async (language: string): Promise => {
if (!documentId) return;
- let next = DEFAULT_DOCUMENT_SETTINGS;
- setSettings((prev) => {
- next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
- ...prev,
- schemaVersion: 1,
- language,
- });
- return next;
+ const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
+ ...settings,
+ schemaVersion: 1,
+ language,
});
try {
- const response = await putDocumentSettings(documentId, next);
- setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
+ await mutation.mutateAsync(next);
} catch (error) {
console.warn('Failed to persist document language:', error);
}
- }, [documentId]);
+ }, [documentId, mutation, settings]);
return {
language: settings.language ?? 'auto',
diff --git a/src/hooks/useDocumentProgress.ts b/src/hooks/useDocumentProgress.ts
new file mode 100644
index 0000000..cd8ad6e
--- /dev/null
+++ b/src/hooks/useDocumentProgress.ts
@@ -0,0 +1,54 @@
+'use client';
+
+import { useCallback, useEffect, useRef } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { getDocumentProgress, putDocumentProgress } from '@/lib/client/api/user-state';
+import { queryKeys } from '@/lib/client/query-keys';
+import { useAuthSession } from '@/hooks/useAuthSession';
+import type { DocumentProgressRecord, ReaderType } from '@/types/user-state';
+
+export function useDocumentProgress(documentId: string | undefined) {
+ const { data: session, isPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
+ const key = queryKeys.progress(sessionId, documentId ?? '');
+ const queryClient = useQueryClient();
+ const timer = useRef | null>(null);
+ const query = useQuery({
+ queryKey: key,
+ queryFn: ({ signal }) => getDocumentProgress(documentId!, { signal }),
+ enabled: !isPending && !!documentId,
+ });
+ const mutation = useMutation({
+ mutationFn: putDocumentProgress,
+ onMutate: async (payload) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previous = queryClient.getQueryData(key);
+ queryClient.setQueryData(key, {
+ documentId: payload.documentId,
+ readerType: payload.readerType,
+ location: payload.location,
+ progress: payload.progress ?? null,
+ clientUpdatedAtMs: Date.now(),
+ updatedAtMs: Date.now(),
+ });
+ return { previous };
+ },
+ onError: (_error, _payload, context) => queryClient.setQueryData(key, context?.previous),
+ onSuccess: (data) => queryClient.setQueryData(key, data),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
+ });
+ const mutateProgress = mutation.mutate;
+ const schedule = useCallback((payload: {
+ documentId: string;
+ readerType: ReaderType;
+ location: string;
+ progress?: number | null;
+ }, debounceMs = 1000) => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(() => mutateProgress(payload), debounceMs);
+ }, [mutateProgress]);
+ useEffect(() => () => {
+ if (timer.current) clearTimeout(timer.current);
+ }, []);
+ return { query, mutation, schedule };
+}
diff --git a/src/hooks/useDocumentSettings.ts b/src/hooks/useDocumentSettings.ts
new file mode 100644
index 0000000..915c5ce
--- /dev/null
+++ b/src/hooks/useDocumentSettings.ts
@@ -0,0 +1,32 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
+import { queryKeys } from '@/lib/client/query-keys';
+import { useAuthSession } from '@/hooks/useAuthSession';
+import type { DocumentSettings } from '@/types/document-settings';
+
+export function useDocumentSettings(documentId: string | undefined) {
+ const { data: session, isPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
+ const key = queryKeys.documentSettings(sessionId, documentId ?? '');
+ const queryClient = useQueryClient();
+ const query = useQuery({
+ queryKey: key,
+ queryFn: ({ signal }) => getDocumentSettings(documentId!, { signal }),
+ enabled: !isPending && !!documentId,
+ });
+ const mutation = useMutation({
+ mutationFn: (settings: DocumentSettings) => putDocumentSettings(documentId!, settings),
+ onMutate: async (settings) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previous = queryClient.getQueryData(key);
+ queryClient.setQueryData(key, { settings, clientUpdatedAtMs: Date.now(), hasStoredSettings: true });
+ return { previous };
+ },
+ onError: (_error, _settings, context) => queryClient.setQueryData(key, context?.previous),
+ onSuccess: (data) => queryClient.setQueryData(key, data),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
+ });
+ return { query, mutation };
+}
diff --git a/src/hooks/useFolders.ts b/src/hooks/useFolders.ts
new file mode 100644
index 0000000..715a9da
--- /dev/null
+++ b/src/hooks/useFolders.ts
@@ -0,0 +1,103 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/client/query-keys';
+import { useAuthSession } from '@/hooks/useAuthSession';
+import type { BaseDocument } from '@/types/documents';
+
+export type ServerFolder = { id: string; name: string; position: number; createdAt?: number; updatedAt?: number };
+
+async function jsonRequest(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, init);
+ if (!res.ok) {
+ const data = await res.json().catch(() => null) as { error?: string } | null;
+ throw new Error(data?.error || 'Folder request failed');
+ }
+ return res.json() as Promise;
+}
+
+export function useFolders() {
+ const { data: session, isPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
+ const foldersKey = queryKeys.folders(sessionId);
+ const documentsKey = queryKeys.documents(sessionId);
+ const queryClient = useQueryClient();
+ const query = useQuery({
+ queryKey: foldersKey,
+ queryFn: async ({ signal }) => (await jsonRequest<{ folders: ServerFolder[] }>('/api/folders', { signal })).folders,
+ enabled: !isPending,
+ });
+ const create = useMutation({
+ mutationFn: (input: { id?: string; name: string; documentIds?: string[] }) => jsonRequest<{ folder: ServerFolder }>('/api/folders', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
+ }),
+ onSuccess: ({ folder }, input) => {
+ queryClient.setQueryData(foldersKey, (rows = []) => [...rows, folder]);
+ if (input.documentIds?.length) queryClient.setQueryData(documentsKey, (rows = []) =>
+ rows.map((doc) => input.documentIds!.includes(doc.id) ? { ...doc, folderId: folder.id } : doc));
+ },
+ onSettled: () => Promise.all([
+ queryClient.invalidateQueries({ queryKey: foldersKey }),
+ queryClient.invalidateQueries({ queryKey: documentsKey }),
+ ]),
+ });
+ const move = useMutation({
+ mutationFn: (input: { documentIds: string[]; folderId: string | null }) => jsonRequest('/api/documents/folders', {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
+ }),
+ onMutate: async (input) => {
+ await queryClient.cancelQueries({ queryKey: documentsKey });
+ const previous = queryClient.getQueryData(documentsKey);
+ queryClient.setQueryData(documentsKey, (rows = []) =>
+ rows.map((doc) => input.documentIds.includes(doc.id) ? { ...doc, folderId: input.folderId ?? undefined } : doc));
+ return { previous };
+ },
+ onError: (_error, _input, context) => queryClient.setQueryData(documentsKey, context?.previous),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: documentsKey }),
+ });
+ const remove = useMutation({
+ mutationFn: (id: string) => jsonRequest(`/api/folders/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ onMutate: async (id) => {
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: foldersKey }),
+ queryClient.cancelQueries({ queryKey: documentsKey }),
+ ]);
+ const previousFolders = queryClient.getQueryData(foldersKey);
+ const previousDocuments = queryClient.getQueryData(documentsKey);
+ queryClient.setQueryData(foldersKey, (rows = []) => rows.filter((folder) => folder.id !== id));
+ queryClient.setQueryData(documentsKey, (rows = []) => rows.map((doc) => doc.folderId === id ? { ...doc, folderId: undefined } : doc));
+ return { previousFolders, previousDocuments };
+ },
+ onError: (_error, _id, context) => {
+ queryClient.setQueryData(foldersKey, context?.previousFolders);
+ queryClient.setQueryData(documentsKey, context?.previousDocuments);
+ },
+ onSettled: () => Promise.all([
+ queryClient.invalidateQueries({ queryKey: foldersKey }),
+ queryClient.invalidateQueries({ queryKey: documentsKey }),
+ ]),
+ });
+ const clear = useMutation({
+ mutationFn: () => jsonRequest('/api/folders', { method: 'DELETE' }),
+ onMutate: async () => {
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: foldersKey }),
+ queryClient.cancelQueries({ queryKey: documentsKey }),
+ ]);
+ const previousFolders = queryClient.getQueryData(foldersKey);
+ const previousDocuments = queryClient.getQueryData(documentsKey);
+ queryClient.setQueryData(foldersKey, []);
+ queryClient.setQueryData(documentsKey, (rows = []) => rows.map((doc) => ({ ...doc, folderId: undefined })));
+ return { previousFolders, previousDocuments };
+ },
+ onError: (_error, _input, context) => {
+ queryClient.setQueryData(foldersKey, context?.previousFolders);
+ queryClient.setQueryData(documentsKey, context?.previousDocuments);
+ },
+ onSettled: () => Promise.all([
+ queryClient.invalidateQueries({ queryKey: foldersKey }),
+ queryClient.invalidateQueries({ queryKey: documentsKey }),
+ ]),
+ });
+ return { query, create, move, remove, clear };
+}
diff --git a/src/hooks/useOnboardingState.ts b/src/hooks/useOnboardingState.ts
new file mode 100644
index 0000000..f5bc1a0
--- /dev/null
+++ b/src/hooks/useOnboardingState.ts
@@ -0,0 +1,47 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/client/query-keys';
+import { useAuthSession } from '@/hooks/useAuthSession';
+
+export type OnboardingState = { privacyAcceptedAtMs: number | null; lastSeenAppVersion: string | null };
+
+async function fetchOnboarding(signal?: AbortSignal): Promise {
+ const res = await fetch('/api/user/state/onboarding', { signal });
+ if (!res.ok) throw new Error('Failed to load onboarding state');
+ return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
+}
+
+export function useOnboardingState() {
+ const { data: session, isPending } = useAuthSession();
+ const sessionId = session?.user?.id ?? 'no-session';
+ const key = queryKeys.onboarding(sessionId);
+ const queryClient = useQueryClient();
+ const query = useQuery({ queryKey: key, queryFn: ({ signal }) => fetchOnboarding(signal), enabled: !isPending });
+ const mutation = useMutation({
+ mutationFn: async (patch: { privacyAccepted?: boolean; lastSeenAppVersion?: string }) => {
+ const res = await fetch('/api/user/state/onboarding', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ if (!res.ok) throw new Error('Failed to update onboarding state');
+ return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
+ },
+ onMutate: async (patch) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previous = queryClient.getQueryData(key);
+ queryClient.setQueryData(key, (current) => ({
+ privacyAcceptedAtMs: patch.privacyAccepted === undefined
+ ? current?.privacyAcceptedAtMs ?? null
+ : patch.privacyAccepted ? Date.now() : null,
+ lastSeenAppVersion: patch.lastSeenAppVersion ?? current?.lastSeenAppVersion ?? null,
+ }));
+ return { previous };
+ },
+ onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
+ onSuccess: (data) => queryClient.setQueryData(key, data),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
+ });
+ return { query, mutation };
+}
diff --git a/src/hooks/useUserPreferences.ts b/src/hooks/useUserPreferences.ts
new file mode 100644
index 0000000..6b8bb4b
--- /dev/null
+++ b/src/hooks/useUserPreferences.ts
@@ -0,0 +1,29 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
+import { queryKeys } from '@/lib/client/query-keys';
+import type { SyncedPreferencesPatch } from '@/types/user-state';
+
+export function useUserPreferences(sessionId: string, enabled: boolean) {
+ const queryClient = useQueryClient();
+ const key = queryKeys.preferences(sessionId);
+ const query = useQuery({ queryKey: key, queryFn: ({ signal }) => getUserPreferences({ signal }), enabled });
+ const mutation = useMutation({
+ mutationFn: (patch: SyncedPreferencesPatch) => putUserPreferences(patch),
+ onMutate: async (patch) => {
+ await queryClient.cancelQueries({ queryKey: key });
+ const previous = queryClient.getQueryData>>(key);
+ queryClient.setQueryData(key, {
+ preferences: { ...(previous?.preferences ?? {}), ...patch },
+ clientUpdatedAtMs: Date.now(),
+ hasStoredPreferences: true,
+ });
+ return { previous };
+ },
+ onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
+ onSuccess: (data) => queryClient.setQueryData(key, data),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
+ });
+ return { query, mutation };
+}
diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts
index 8d05a09..8bc3703 100644
--- a/src/lib/client/api/documents.ts
+++ b/src/lib/client/api/documents.ts
@@ -85,7 +85,11 @@ export async function listDocuments(options?: { ids?: string[]; signal?: AbortSi
export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise {
const docs = await listDocuments({ ids: [id], signal: options?.signal });
- return docs[0] ?? null;
+ const document = docs[0] ?? null;
+ if (document) {
+ void fetch(`/api/documents/${encodeURIComponent(id)}/opened`, { method: 'PUT' }).catch(() => {});
+ }
+ return document;
}
export class ParsedPdfNotReadyError extends Error {
@@ -439,9 +443,13 @@ export async function deleteDocuments(options?: { ids?: string[]; signal?: Abort
}
export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise {
+ return (await fetchDocumentContentResponse(id, options)).arrayBuffer();
+}
+
+export async function fetchDocumentContentResponse(id: string, options?: { signal?: AbortSignal }): Promise {
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`;
- const fetchFallback = async (): Promise => {
+ const fetchFallback = async (): Promise => {
const res = await fetch(fallbackUrl, { signal: options?.signal });
if (!res.ok) {
const contentType = res.headers.get('content-type') || '';
@@ -451,7 +459,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
}
throw new Error(`Failed to download document (status ${res.status})`);
}
- return res.arrayBuffer();
+ return res;
};
try {
@@ -467,7 +475,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
}
throw new Error(`Failed to download document (status ${directRes.status})`);
}
- return directRes.arrayBuffer();
+ return directRes;
} catch (error) {
if (options?.signal?.aborted) throw error;
return fetchFallback();
@@ -508,6 +516,7 @@ export type DocumentPreviewReady = {
fallbackUrl: string;
presignUrl: string;
directUrl?: string;
+ previewVersion: string;
};
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
@@ -556,12 +565,14 @@ export async function getDocumentPreviewStatus(
fallbackUrl?: string;
presignUrl?: string;
directUrl?: string;
+ previewVersion?: string;
} | null;
return {
kind: 'ready',
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
directUrl: data?.directUrl,
+ previewVersion: data?.previewVersion || '',
};
}
@@ -606,4 +617,3 @@ export async function importUrl(
return (await res.json()) as { title: string; content: string };
}
-
diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts
index d9e8cf1..27ea086 100644
--- a/src/lib/client/api/user-state.ts
+++ b/src/lib/client/api/user-state.ts
@@ -78,102 +78,6 @@ export async function postChangelogVersionCheck(
return (await res.json()) as ChangelogVersionCheckResponse;
}
-type PendingPreferenceSync = {
- patch: SyncedPreferencesPatch;
- timer: ReturnType | null;
- sessionId: string | null;
-};
-
-const pendingPreferenceSync: PendingPreferenceSync = {
- patch: {},
- timer: null,
- sessionId: null,
-};
-
-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, { signal: activeSyncController.signal });
- } catch (error) {
- if ((error as Error)?.name === 'AbortError') return;
- console.warn('Failed to sync user preferences:', error);
- } finally {
- activeSyncController = null;
- }
- }, debounceMs);
-}
-
-/**
- * Immediately send any pending debounced preference patch and await the result.
- * Use this when the user explicitly saves: we want the server write to complete
- * (and surface failures) instead of silently relying on the debounce timer, which
- * can be lost if the page is reloaded inside the debounce window.
- */
-export async function flushUserPreferencesSync(): Promise {
- if (pendingPreferenceSync.timer) {
- clearTimeout(pendingPreferenceSync.timer);
- pendingPreferenceSync.timer = null;
- }
-
- const payload = { ...pendingPreferenceSync.patch };
- pendingPreferenceSync.patch = {};
- if (Object.keys(payload).length === 0) return;
-
- if (activeSyncController) activeSyncController.abort();
- activeSyncController = new AbortController();
-
- try {
- await putUserPreferences(payload, { signal: activeSyncController.signal });
- } finally {
- activeSyncController = null;
- }
-}
-
export async function getDocumentProgress(
documentId: string,
options?: { signal?: AbortSignal },
@@ -216,56 +120,3 @@ export async function putDocumentProgress(payload: {
const data = (await res.json()) as ProgressResponse;
return data.progress ?? null;
}
-
-type PendingProgressSync = {
- payload: {
- documentId: string;
- readerType: ReaderType;
- location: string;
- progress: number | null;
- };
- timer: ReturnType | null;
-};
-
-const pendingProgressByDoc = new Map();
-
-export function scheduleDocumentProgressSync(
- payload: {
- documentId: string;
- readerType: ReaderType;
- location: string;
- progress?: number | null;
- },
- debounceMs: number = 1000,
-): void {
- const existing = pendingProgressByDoc.get(payload.documentId);
- if (existing?.timer) {
- clearTimeout(existing.timer);
- }
-
- const next: PendingProgressSync = {
- payload: {
- documentId: payload.documentId,
- readerType: payload.readerType,
- location: payload.location,
- progress: payload.progress ?? null,
- },
- timer: null,
- };
-
- next.timer = setTimeout(async () => {
- pendingProgressByDoc.delete(payload.documentId);
- try {
- await putDocumentProgress({
- documentId: next.payload.documentId,
- readerType: next.payload.readerType,
- location: next.payload.location,
- progress: next.payload.progress,
- });
- } catch (error) {
- console.warn('Failed to sync document progress:', error);
- }
- }, debounceMs);
-
- pendingProgressByDoc.set(payload.documentId, next);
-}
diff --git a/src/lib/client/audiobooks/pipeline.ts b/src/lib/client/audiobooks/pipeline.ts
index d2e7c4e..6e232a8 100644
--- a/src/lib/client/audiobooks/pipeline.ts
+++ b/src/lib/client/audiobooks/pipeline.ts
@@ -29,8 +29,6 @@ export interface AudiobookSourceAdapter {
interface RunAudiobookGenerationOptions {
adapter: AudiobookSourceAdapter;
- apiKey: string;
- baseUrl: string;
defaultProvider: string;
onProgress: (progress: number) => void;
signal?: AbortSignal;
@@ -47,8 +45,6 @@ interface RegenerateAudiobookChapterOptions {
bookId: string;
format: TTSAudiobookFormat;
signal: AbortSignal;
- apiKey: string;
- baseUrl: string;
defaultProvider: string;
settings?: AudiobookGenerationSettings;
retryOptions?: TTSRetryOptions;
@@ -71,14 +67,10 @@ function resolveAudiobookRequestSettings(
}
function buildAudiobookRequestHeaders(
- apiKey: string,
- baseUrl: string,
effectiveProvider: string,
): TTSRequestHeaders {
return {
'Content-Type': 'application/json',
- 'x-openai-key': apiKey,
- 'x-openai-base-url': baseUrl,
'x-tts-provider': effectiveProvider,
};
}
@@ -95,8 +87,6 @@ function createAudiobookAbortError(): Error {
export async function runAudiobookGeneration({
adapter,
- apiKey,
- baseUrl,
defaultProvider,
onProgress,
signal,
@@ -120,7 +110,7 @@ export async function runAudiobookGeneration({
}
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
- const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
+ const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
let processedLength = 0;
let bookId = providedBookId;
@@ -221,8 +211,6 @@ export async function regenerateAudiobookChapter({
bookId,
format,
signal,
- apiKey,
- baseUrl,
defaultProvider,
settings,
retryOptions = {
@@ -238,7 +226,7 @@ export async function regenerateAudiobookChapter({
}
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
- const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
+ const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
return withRetry(
async () => {
diff --git a/src/lib/client/cache/audio.ts b/src/lib/client/cache/audio.ts
new file mode 100644
index 0000000..34c4d7e
--- /dev/null
+++ b/src/lib/client/cache/audio.ts
@@ -0,0 +1,28 @@
+import { audioBlobCacheKey, getCachedBlob } from '@/lib/client/cache/blob-cache';
+
+const objectUrls = new Map();
+
+export async function getCachedAudioUrl(input: {
+ audioKey: string;
+ version: string | number;
+ primaryUrl: string | null;
+ fallbackUrl: string | null;
+}): Promise {
+ const key = audioBlobCacheKey(input.audioKey, input.version);
+ const existing = objectUrls.get(key);
+ if (existing) return existing;
+ const response = await getCachedBlob(key, async () => {
+ const primary = input.primaryUrl ? await fetch(input.primaryUrl).catch(() => null) : null;
+ if (primary?.ok) return primary;
+ if (!input.fallbackUrl) return primary ?? new Response(null, { status: 404 });
+ return fetch(input.fallbackUrl);
+ });
+ const url = URL.createObjectURL(await response.blob());
+ objectUrls.set(key, url);
+ return url;
+}
+
+export function clearCachedAudioObjectUrls(): void {
+ for (const url of objectUrls.values()) URL.revokeObjectURL(url);
+ objectUrls.clear();
+}
diff --git a/src/lib/client/cache/blob-cache.ts b/src/lib/client/cache/blob-cache.ts
new file mode 100644
index 0000000..452e33c
--- /dev/null
+++ b/src/lib/client/cache/blob-cache.ts
@@ -0,0 +1,60 @@
+const BLOB_CACHE_NAME = 'openreader-blobs-v1';
+
+function canUseCacheStorage(): boolean {
+ return typeof window !== 'undefined' && typeof caches !== 'undefined';
+}
+
+export async function getCachedBlob(
+ stableKey: string,
+ fetchSource: () => Promise,
+): Promise {
+ let cache: Cache | null = null;
+ if (canUseCacheStorage()) {
+ try {
+ cache = await caches.open(BLOB_CACHE_NAME);
+ const cached = await cache.match(stableKey);
+ if (cached) return cached;
+ } catch {
+ cache = null;
+ }
+ }
+
+ const response = await fetchSource();
+ if (!response.ok) throw new Error(`Blob fetch failed: ${response.status}`);
+
+ if (cache && response.status === 200 && response.type !== 'opaque' && !response.headers.has('Content-Range')) {
+ await cache.put(stableKey, response.clone()).catch(() => {});
+ }
+ return response;
+}
+
+export async function putCachedBlob(stableKey: string, response: Response): Promise {
+ if (!canUseCacheStorage() || !response.ok || response.status !== 200 || response.type === 'opaque') return;
+ const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
+ await cache?.put(stableKey, response.clone()).catch(() => {});
+}
+
+export async function evictCachedBlobPrefix(prefix: string): Promise {
+ if (!canUseCacheStorage()) return;
+ const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
+ if (!cache) return;
+ const keys = await cache.keys().catch(() => []);
+ await Promise.allSettled(keys.filter((request) => new URL(request.url).pathname.startsWith(prefix)).map((request) => cache.delete(request)));
+}
+
+export async function clearBlobCache(): Promise {
+ if (!canUseCacheStorage()) return;
+ await caches.delete(BLOB_CACHE_NAME).catch(() => {});
+}
+
+export function documentBlobCacheKey(documentId: string, contentVersion: string): string {
+ return `/openreader-cache/documents/${encodeURIComponent(documentId)}/${encodeURIComponent(contentVersion)}`;
+}
+
+export function previewBlobCacheKey(documentId: string, previewVersion: string | number): string {
+ return `/openreader-cache/previews/${encodeURIComponent(documentId)}/${encodeURIComponent(String(previewVersion))}`;
+}
+
+export function audioBlobCacheKey(audioKey: string, version: string | number): string {
+ return `/openreader-cache/audio/${encodeURIComponent(audioKey)}/${encodeURIComponent(String(version))}`;
+}
diff --git a/src/lib/client/cache/documents.ts b/src/lib/client/cache/documents.ts
index ef3aa06..81aaf47 100644
--- a/src/lib/client/cache/documents.ts
+++ b/src/lib/client/cache/documents.ts
@@ -1,152 +1,50 @@
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents';
-import { downloadDocumentContent } from '@/lib/client/api/documents';
+import { fetchDocumentContentResponse } from '@/lib/client/api/documents';
+import {
+ clearBlobCache,
+ documentBlobCacheKey,
+ evictCachedBlobPrefix,
+ getCachedBlob,
+ putCachedBlob,
+} from '@/lib/client/cache/blob-cache';
-export type DocumentCacheBackend = {
- get: (meta: BaseDocument) => Promise;
- putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise;
- putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise;
- putHtml: (meta: BaseDocument, data: string) => Promise;
- download: (id: string, options?: { signal?: AbortSignal }) => Promise;
- decodeText: (buffer: ArrayBuffer) => string;
-};
+function stableKey(meta: BaseDocument): string {
+ return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id);
+}
-export async function ensureCachedDocumentCore(
+async function readDocument(meta: BaseDocument, response: Response): Promise {
+ if (meta.type === 'html') {
+ return { ...meta, type: 'html', data: await response.text() };
+ }
+ const data = await response.arrayBuffer();
+ if (meta.type === 'epub') return { ...meta, type: 'epub', data };
+ return { ...meta, type: 'pdf', data };
+}
+
+export async function ensureCachedDocument(
meta: BaseDocument,
- backend: DocumentCacheBackend,
options?: { signal?: AbortSignal },
): Promise {
- const cached = await backend.get(meta);
- if (cached) return cached;
-
- const buffer = await backend.download(meta.id, { signal: options?.signal });
-
- if (meta.type === 'pdf') {
- await backend.putPdf(meta, buffer);
- const after = await backend.get(meta);
- if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF');
- return after;
+ if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') {
+ throw new Error(`Unsupported cached document type: ${meta.type}`);
}
-
- if (meta.type === 'epub') {
- await backend.putEpub(meta, buffer);
- const after = await backend.get(meta);
- if (!after || after.type !== 'epub') throw new Error('Failed to cache EPUB');
- return after;
- }
-
- const decoded = backend.decodeText(buffer);
- await backend.putHtml(meta, decoded);
- const after = await backend.get(meta);
- if (!after || after.type !== 'html') throw new Error('Failed to cache HTML');
- return after;
-}
-
-export async function getCachedPdf(id: string): Promise {
- const { getPdfDocument } = await import('@/lib/client/dexie');
- return (await getPdfDocument(id)) ?? null;
-}
-
-export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise {
- const { addPdfDocument } = await import('@/lib/client/dexie');
- await addPdfDocument({
- id: meta.id,
- type: 'pdf',
- name: meta.name,
- size: meta.size,
- lastModified: meta.lastModified,
- data,
- });
-}
-
-export async function evictCachedPdf(id: string): Promise {
- const { removePdfDocument } = await import('@/lib/client/dexie');
- await removePdfDocument(id);
-}
-
-export async function getCachedEpub(id: string): Promise {
- const { getEpubDocument } = await import('@/lib/client/dexie');
- return (await getEpubDocument(id)) ?? null;
-}
-
-export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise {
- const { addEpubDocument } = await import('@/lib/client/dexie');
- await addEpubDocument({
- id: meta.id,
- type: 'epub',
- name: meta.name,
- size: meta.size,
- lastModified: meta.lastModified,
- data,
- });
-}
-
-export async function evictCachedEpub(id: string): Promise {
- const { removeEpubDocument } = await import('@/lib/client/dexie');
- await removeEpubDocument(id);
-}
-
-export async function getCachedHtml(id: string): Promise {
- const { getHtmlDocument } = await import('@/lib/client/dexie');
- return (await getHtmlDocument(id)) ?? null;
-}
-
-export async function putCachedHtml(meta: BaseDocument, data: string): Promise {
- const { addHtmlDocument } = await import('@/lib/client/dexie');
- await addHtmlDocument({
- id: meta.id,
- type: 'html',
- name: meta.name,
- size: meta.size,
- lastModified: meta.lastModified,
- data,
- });
-}
-
-export async function evictCachedHtml(id: string): Promise {
- const { removeHtmlDocument } = await import('@/lib/client/dexie');
- await removeHtmlDocument(id);
-}
-
-export async function evictCachedDocument(id: string): Promise {
- await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
-}
-
-export async function clearDocumentCache(): Promise {
- const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie');
- await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]);
+ const response = await getCachedBlob(stableKey(meta), () => fetchDocumentContentResponse(meta.id, options));
+ return readDocument(meta, response);
}
export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise {
- if (stored.type === 'pdf') {
- await putCachedPdf(stored, bytes);
- return;
- }
- if (stored.type === 'epub') {
- await putCachedEpub(stored, bytes);
- return;
- }
- if (stored.type === 'html') {
- const decoded = new TextDecoder().decode(new Uint8Array(bytes));
- await putCachedHtml(stored, decoded);
- }
+ const type = stored.type === 'pdf'
+ ? 'application/pdf'
+ : stored.type === 'epub'
+ ? 'application/epub+zip'
+ : 'text/plain; charset=utf-8';
+ await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } }));
}
-export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise {
- return ensureCachedDocumentCore(
- meta,
- {
- get: async (m) => {
- const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie');
- if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null;
- if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null;
- return (await getHtmlDocument(m.id)) ?? null;
- },
- putPdf: putCachedPdf,
- putEpub: putCachedEpub,
- putHtml: putCachedHtml,
- download: downloadDocumentContent,
- decodeText: (buffer) => new TextDecoder().decode(new Uint8Array(buffer)),
- },
- options,
- );
+export async function evictCachedDocument(id: string): Promise {
+ await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`);
+}
+
+export async function clearDocumentCache(): Promise {
+ await clearBlobCache();
}
diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts
index 9fb067f..3bc85d9 100644
--- a/src/lib/client/cache/previews.ts
+++ b/src/lib/client/cache/previews.ts
@@ -1,24 +1,11 @@
-import {
- clearDocumentPreviewCache as clearPersistedDocumentPreviewCache,
- getDocumentPreviewCache,
- putDocumentPreviewCache,
- removeDocumentPreviewCache,
-} from '@/lib/client/dexie';
import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
+import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache';
const inMemoryPreviewUrlCache = new Map();
const inFlightPreviewPrime = new Map>();
-const inFlightPersistedPreviewUrl = new Map>();
-const PREVIEW_CACHE_SCHEMA_VERSION = 4;
function revokeIfBlobUrl(url: string | null | undefined): void {
- if (!url) return;
- if (!url.startsWith('blob:')) return;
- try {
- URL.revokeObjectURL(url);
- } catch {
- // ignore
- }
+ if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
}
export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
@@ -27,131 +14,62 @@ export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
const prev = inMemoryPreviewUrlCache.get(cacheKey);
- if (prev && prev !== url) {
- revokeIfBlobUrl(prev);
- }
+ if (prev && prev !== url) revokeIfBlobUrl(prev);
inMemoryPreviewUrlCache.set(cacheKey, url);
}
export function clearInMemoryDocumentPreviewCache(): void {
- for (const value of inMemoryPreviewUrlCache.values()) {
- revokeIfBlobUrl(value);
- }
+ for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value);
inMemoryPreviewUrlCache.clear();
- inFlightPersistedPreviewUrl.clear();
+}
+
+async function fetchPreviewSource(docId: string, signal?: AbortSignal): Promise {
+ const options = { signal, cache: 'no-store' as const };
+ const direct = await fetch(documentPreviewPresignUrl(docId), options).catch(() => null);
+ if (direct?.ok) return direct;
+ return fetch(documentPreviewFallbackUrl(docId), options);
}
export async function getPersistedDocumentPreviewUrl(
docId: string,
- lastModified: number,
+ previewVersion: string | number,
cacheKey: string,
): Promise {
- const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
- if (cachedUrl) return cachedUrl;
-
- const persistedKey = `${cacheKey}:${Number(lastModified)}`;
- const existing = inFlightPersistedPreviewUrl.get(persistedKey);
- if (existing) {
- return existing;
- }
-
- const promise = (async (): Promise => {
- const row = await getDocumentPreviewCache(docId);
- if (!row) return null;
-
- if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) {
- await removeDocumentPreviewCache(docId).catch(() => {});
- return null;
- }
-
- if (Number(row.lastModified) !== Number(lastModified)) {
- await removeDocumentPreviewCache(docId).catch(() => {});
- return null;
- }
-
- const latestCachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
- if (latestCachedUrl) return latestCachedUrl;
-
- const contentType = row.contentType || 'image/jpeg';
- const bytes = row.data;
- if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) {
- await removeDocumentPreviewCache(docId).catch(() => {});
- return null;
- }
-
- const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
- setInMemoryDocumentPreviewUrl(cacheKey, url);
- return url;
- })();
-
- inFlightPersistedPreviewUrl.set(persistedKey, promise);
- try {
- return await promise;
- } finally {
- if (inFlightPersistedPreviewUrl.get(persistedKey) === promise) {
- inFlightPersistedPreviewUrl.delete(persistedKey);
- }
- }
+ return primeDocumentPreviewCache(docId, previewVersion, cacheKey);
}
export async function primeDocumentPreviewCache(
docId: string,
- lastModified: number,
+ previewVersion: string | number,
cacheKey: string,
options?: { signal?: AbortSignal },
): Promise {
- const primeKey = `${cacheKey}:${Number(lastModified)}`;
- const existingPrime = inFlightPreviewPrime.get(primeKey);
- if (existingPrime) {
- return existingPrime;
- }
-
- const promise = (async (): Promise => {
- const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey);
+ const memory = getInMemoryDocumentPreviewUrl(cacheKey);
+ if (memory) return memory;
+ const primeKey = `${docId}:${previewVersion}`;
+ const existing = inFlightPreviewPrime.get(primeKey);
if (existing) return existing;
-
- const fetchOptions = {
- signal: options?.signal,
- cache: 'no-store' as const,
- };
-
- // Prefer presign path for priming so healthy direct object access avoids proxy load.
- let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null);
- if (!res || !res.ok) {
- res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null);
- }
- if (!res || !res.ok) return null;
-
- const blob = await res.blob();
- const bytes = await blob.arrayBuffer();
- if (bytes.byteLength === 0) return null;
-
- const contentType = blob.type || 'image/jpeg';
- await putDocumentPreviewCache({
- docId,
- lastModified: Number(lastModified),
- contentType,
- data: bytes,
- cachedAt: Date.now(),
- previewVersion: PREVIEW_CACHE_SCHEMA_VERSION,
- });
-
- const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
- setInMemoryDocumentPreviewUrl(cacheKey, url);
- return url;
+ const promise = (async () => {
+ const response = await getCachedBlob(
+ previewBlobCacheKey(docId, previewVersion),
+ () => fetchPreviewSource(docId, options?.signal),
+ ).catch(() => null);
+ if (!response?.ok) return null;
+ const blob = await response.blob();
+ if (blob.size === 0) return null;
+ const url = URL.createObjectURL(blob);
+ setInMemoryDocumentPreviewUrl(cacheKey, url);
+ return url;
})();
-
inFlightPreviewPrime.set(primeKey, promise);
try {
return await promise;
} finally {
- if (inFlightPreviewPrime.get(primeKey) === promise) {
- inFlightPreviewPrime.delete(primeKey);
- }
+ inFlightPreviewPrime.delete(primeKey);
}
}
export async function clearAllDocumentPreviewCaches(): Promise {
clearInMemoryDocumentPreviewCache();
- await clearPersistedDocumentPreviewCache();
+ await clearBlobCache();
}
diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts
deleted file mode 100644
index 19bc09b..0000000
--- a/src/lib/client/dexie.ts
+++ /dev/null
@@ -1,1285 +0,0 @@
-import Dexie, { type EntityTable } from 'dexie';
-import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
-import {
- isTtsProviderType,
- type TtsProviderType,
-} from '@/lib/shared/tts-provider-catalog';
-import { defaultModelForProviderType, normalizeLegacyProviderRef, resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
-import {
- PDFDocument,
- EPUBDocument,
- HTMLDocument,
- DocumentListState,
- BaseDocument,
- DocumentListDocument,
-} from '@/types/documents';
-import { sha256HexFromBytes, sha256HexFromString } from '@/lib/client/sha256';
-import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client/api/documents';
-import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
-
-const DB_NAME = 'openreader-db';
-// Managed via Dexie (version bumped from the original manual IndexedDB)
-const DB_VERSION = 9;
-
-const PDF_TABLE = 'pdf-documents' as const;
-const EPUB_TABLE = 'epub-documents' as const;
-const HTML_TABLE = 'html-documents' as const;
-const CONFIG_TABLE = 'config' as const;
-const APP_CONFIG_TABLE = 'app-config' as const;
-const LAST_LOCATION_TABLE = 'last-locations' as const;
-const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const;
-const PREVIEW_CACHE_TABLE = 'document-preview-cache' as const;
-const MIB = 1024 * 1024;
-const DOCUMENT_CACHE_MAX_BYTES = 1024 * MIB; // 1 GiB
-const PREVIEW_CACHE_MAX_BYTES = 128 * MIB; // 128 MiB
-
-interface DocumentCacheMeta {
- cacheCreatedAt?: number;
- cacheAccessedAt?: number;
- cacheByteSize?: number;
-}
-
-type PDFCacheRow = PDFDocument & DocumentCacheMeta;
-type EPUBCacheRow = EPUBDocument & DocumentCacheMeta;
-type HTMLCacheRow = HTMLDocument & DocumentCacheMeta;
-
-export interface LastLocationRow {
- docId: string;
- location: string;
-}
-
-export interface DocumentIdMapRow {
- oldId: string;
- id: string;
- createdAt: number;
-}
-
-export interface DocumentPreviewCacheRow {
- docId: string;
- lastModified: number;
- contentType: string;
- data: ArrayBuffer;
- cachedAt: number;
- byteSize?: number;
- previewVersion?: number;
-}
-
-export interface ConfigRow {
- key: string;
- value: string;
-}
-
-type OpenReaderDB = Dexie & {
- [PDF_TABLE]: EntityTable;
- [EPUB_TABLE]: EntityTable;
- [HTML_TABLE]: EntityTable;
- [CONFIG_TABLE]: EntityTable;
- [APP_CONFIG_TABLE]: EntityTable;
- [LAST_LOCATION_TABLE]: EntityTable;
- [DOCUMENT_ID_MAP_TABLE]: EntityTable;
- [PREVIEW_CACHE_TABLE]: EntityTable;
-};
-
-export const db = new Dexie(DB_NAME) as OpenReaderDB;
-
-
-
-type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error';
-
-function emitDexieStatus(status: DexieOpenStatus, detail?: Record): void {
- if (typeof window === 'undefined') return;
- try {
- window.dispatchEvent(
- new CustomEvent('openreader:dexie', {
- detail: { status, ...detail },
- }),
- );
- } catch {
- // ignore
- }
-}
-
-if (typeof window !== 'undefined') {
- // Fired when this tab's open/upgrade is blocked by another tab holding the DB open.
- db.on('blocked', () => {
- emitDexieStatus('blocked');
- });
-}
-
-const PROVIDER_DEFAULT_BASE_URL: Record = {
- openai: 'https://api.openai.com/v1',
- deepinfra: 'https://api.deepinfra.com/v1/openai',
- 'custom-openai': '',
-};
-
-type RawConfigMap = Record;
-
-function inferProviderRefAndBaseUrl(raw: RawConfigMap): { providerRef: string; baseUrl: string } {
- const cachedApiKey = raw.apiKey;
- const cachedBaseUrl = raw.baseUrl;
- let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || '';
- inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef);
-
- if (!raw.providerRef && !raw.ttsProvider && !raw.provider) {
- inferredProviderRef = getAppConfigDefaults().providerRef;
- } else if (!inferredProviderRef) {
- if (cachedBaseUrl) {
- const baseUrlLower = cachedBaseUrl.toLowerCase();
- if (baseUrlLower.includes('deepinfra.com')) {
- inferredProviderRef = 'deepinfra';
- } else if (baseUrlLower.includes('openai.com')) {
- inferredProviderRef = 'openai';
- } else if (
- baseUrlLower.includes('localhost') ||
- baseUrlLower.includes('127.0.0.1') ||
- baseUrlLower.includes('internal')
- ) {
- inferredProviderRef = 'custom-openai';
- } else {
- inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
- }
- } else {
- inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
- }
- }
-
- let baseUrl = cachedBaseUrl || '';
- if (!baseUrl) {
- if (inferredProviderRef === 'openai') {
- baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
- } else if (inferredProviderRef === 'deepinfra') {
- baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
- } else {
- baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
- }
- }
-
- return { providerRef: inferredProviderRef, baseUrl };
-}
-
-function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
- const { providerRef, baseUrl } = inferProviderRefAndBaseUrl(raw);
- const providerTypeRaw = raw.providerType;
- const providerType: TtsProviderType =
- isTtsProviderType(providerTypeRaw)
- ? providerTypeRaw
- : resolveEffectiveProviderType({ providerRef });
-
- let savedVoices: SavedVoices = {};
- if (raw.savedVoices) {
- try {
- savedVoices = JSON.parse(raw.savedVoices) as SavedVoices;
- } catch (error) {
- console.error('Error parsing savedVoices during migration:', error);
- }
- }
-
- let documentListState: DocumentListState = APP_CONFIG_DEFAULTS.documentListState;
- if (raw.documentListState) {
- try {
- documentListState = JSON.parse(raw.documentListState) as DocumentListState;
- } catch (error) {
- console.error('Error parsing documentListState during migration:', error);
- }
- }
-
- const config: AppConfigRow = {
- id: 'singleton',
- ...APP_CONFIG_DEFAULTS,
- apiKey: raw.apiKey ?? APP_CONFIG_DEFAULTS.apiKey,
- baseUrl,
- viewType: (raw.viewType as ViewType) || APP_CONFIG_DEFAULTS.viewType,
- voiceSpeed: raw.voiceSpeed ? parseFloat(raw.voiceSpeed) : APP_CONFIG_DEFAULTS.voiceSpeed,
- audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
- voice: '',
- skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
- epubTheme: raw.epubTheme === 'true',
- headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin,
- footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
- leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
- rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
- providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef,
- providerType,
- ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)),
- ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
- savedVoices,
- pdfHighlightEnabled:
- raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled,
- pdfWordHighlightEnabled:
- raw.pdfWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfWordHighlightEnabled,
- epubHighlightEnabled:
- raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
- epubWordHighlightEnabled:
- raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
- htmlHighlightEnabled:
- raw.htmlHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlHighlightEnabled,
- htmlWordHighlightEnabled:
- raw.htmlWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlWordHighlightEnabled,
- firstVisit: raw.firstVisit === 'true',
- documentListState,
- };
-
- const voiceKey = `${config.providerRef}:${config.ttsModel}`;
- config.voice = config.savedVoices[voiceKey] || '';
-
- return config;
-}
-
-// Version 9: normalize app-config provider fields to providerRef/providerType.
-db.version(DB_VERSION).stores({
- [PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
- [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
- [HTML_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
- [APP_CONFIG_TABLE]: 'id',
- [LAST_LOCATION_TABLE]: 'docId',
- [DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt',
- [PREVIEW_CACHE_TABLE]: 'docId, lastModified, cachedAt, byteSize',
- // `null` here means: drop the old 'config' table after upgrade runs,
- // but Dexie still lets us read it inside the upgrade transaction.
- [CONFIG_TABLE]: null,
-}).upgrade(async (trans) => {
- const appConfigTable = trans.table(APP_CONFIG_TABLE);
- const appConfig = await appConfigTable.get('singleton');
- if (appConfig) {
- const legacyProviderRef = (() => {
- const providerRefRaw = (appConfig as unknown as { providerRef?: unknown }).providerRef;
- if (typeof providerRefRaw === 'string' && providerRefRaw.trim()) return providerRefRaw;
- const ttsProviderRaw = (appConfig as unknown as { ttsProvider?: unknown }).ttsProvider;
- if (typeof ttsProviderRaw === 'string' && ttsProviderRaw.trim()) return ttsProviderRaw;
- const providerRaw = (appConfig as unknown as { provider?: unknown }).provider;
- if (typeof providerRaw === 'string' && providerRaw.trim()) return providerRaw;
- return '';
- })();
- const providerRef = normalizeLegacyProviderRef(legacyProviderRef, APP_CONFIG_DEFAULTS.providerRef) || APP_CONFIG_DEFAULTS.providerRef;
- const providerType = resolveEffectiveProviderType({ providerRef });
- const savedVoices = appConfig.savedVoices && typeof appConfig.savedVoices === 'object'
- ? appConfig.savedVoices
- : {};
- const resolvedVoiceKey = `${providerRef}:${appConfig.ttsModel || APP_CONFIG_DEFAULTS.ttsModel}`;
- const resolvedVoice = savedVoices[resolvedVoiceKey] || '';
- await appConfigTable.put({
- ...appConfig,
- providerRef,
- providerType,
- voice: appConfig.voice || resolvedVoice,
- });
- return;
- }
-
- const configRows = await trans.table(CONFIG_TABLE).toArray();
- const raw: RawConfigMap = {};
-
- for (const row of configRows) {
- raw[row.key] = row.value;
- }
-
- const built = buildAppConfigFromRaw(raw);
- await appConfigTable.put(built);
-
- // Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
- const locationTable = trans.table(LAST_LOCATION_TABLE);
- for (const row of configRows) {
- if (row.key.startsWith('lastLocation_')) {
- const docId = row.key.substring('lastLocation_'.length);
- await locationTable.put({ docId, location: row.value });
- }
- }
-});
-
-let dbOpenPromise: Promise | null = null;
-const cacheTextEncoder = new TextEncoder();
-
-export async function initDB(): Promise {
- if (dbOpenPromise) {
- return dbOpenPromise;
- }
-
- dbOpenPromise = (async () => {
- try {
- console.log('Opening Dexie database...');
- emitDexieStatus('opening');
- const startedAt = Date.now();
- const stallTimer = setTimeout(() => {
- emitDexieStatus('stalled', { ms: Date.now() - startedAt });
- }, 4000);
- await db.open();
- await Promise.all([pruneDocumentCacheIfNeededInternal(), prunePreviewCacheIfNeededInternal()]).catch((error) => {
- console.warn('Dexie cache prune on open failed:', error);
- });
- clearTimeout(stallTimer);
- console.log('Dexie database opened successfully');
- emitDexieStatus('opened');
- } catch (error) {
- console.error('Dexie initialization error:', error);
- emitDexieStatus('error', { message: error instanceof Error ? error.message : String(error) });
- dbOpenPromise = null;
- throw error;
- }
- })();
-
- return dbOpenPromise;
-}
-
-async function withDB(operation: () => Promise): Promise {
- await initDB();
- return operation();
-}
-
-type DocumentCacheTableName = typeof PDF_TABLE | typeof EPUB_TABLE | typeof HTML_TABLE;
-
-type DocumentCacheEntryRef = {
- table: DocumentCacheTableName;
- id: string;
- byteSize: number;
- accessedAt: number;
-};
-
-function toPositiveInt(value: unknown, fallback: number = 0): number {
- const n = Number(value);
- if (!Number.isFinite(n) || n < 0) return fallback;
- return Math.max(0, Math.floor(n));
-}
-
-function byteSizeForPdfRow(row: PDFCacheRow): number {
- const cached = toPositiveInt(row.cacheByteSize, 0);
- if (cached > 0) return cached;
- return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0);
-}
-
-function byteSizeForEpubRow(row: EPUBCacheRow): number {
- const cached = toPositiveInt(row.cacheByteSize, 0);
- if (cached > 0) return cached;
- return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0);
-}
-
-function byteSizeForHtmlRow(row: HTMLCacheRow): number {
- const cached = toPositiveInt(row.cacheByteSize, 0);
- if (cached > 0) return cached;
- if (typeof row.data === 'string') return cacheTextEncoder.encode(row.data).byteLength;
- return toPositiveInt(row.size, 0);
-}
-
-function accessedAtForRow(row: DocumentCacheMeta & { lastModified?: number }): number {
- return toPositiveInt(row.cacheAccessedAt, toPositiveInt(row.lastModified, Date.now()));
-}
-
-async function deleteDocumentCacheEntryInternal(table: DocumentCacheTableName, id: string): Promise {
- if (table === PDF_TABLE) {
- await db[PDF_TABLE].delete(id);
- return;
- }
- if (table === EPUB_TABLE) {
- await db[EPUB_TABLE].delete(id);
- return;
- }
- await db[HTML_TABLE].delete(id);
-}
-
-async function pruneDocumentCacheIfNeededInternal(): Promise {
- const budget = DOCUMENT_CACHE_MAX_BYTES;
- if (!Number.isFinite(budget) || budget <= 0) return;
-
- const [pdfRows, epubRows, htmlRows] = await Promise.all([
- db[PDF_TABLE].toArray(),
- db[EPUB_TABLE].toArray(),
- db[HTML_TABLE].toArray(),
- ]);
-
- const entries: DocumentCacheEntryRef[] = [];
- let totalBytes = 0;
-
- for (const row of pdfRows) {
- const byteSize = byteSizeForPdfRow(row);
- totalBytes += byteSize;
- entries.push({
- table: PDF_TABLE,
- id: row.id,
- byteSize,
- accessedAt: accessedAtForRow(row),
- });
- }
- for (const row of epubRows) {
- const byteSize = byteSizeForEpubRow(row);
- totalBytes += byteSize;
- entries.push({
- table: EPUB_TABLE,
- id: row.id,
- byteSize,
- accessedAt: accessedAtForRow(row),
- });
- }
- for (const row of htmlRows) {
- const byteSize = byteSizeForHtmlRow(row);
- totalBytes += byteSize;
- entries.push({
- table: HTML_TABLE,
- id: row.id,
- byteSize,
- accessedAt: accessedAtForRow(row),
- });
- }
-
- if (totalBytes <= budget) return;
-
- entries.sort((a, b) => a.accessedAt - b.accessedAt);
- for (const entry of entries) {
- if (totalBytes <= budget) break;
- await deleteDocumentCacheEntryInternal(entry.table, entry.id);
- totalBytes -= entry.byteSize;
- }
-}
-
-async function prunePreviewCacheIfNeededInternal(): Promise {
- const budget = PREVIEW_CACHE_MAX_BYTES;
- if (!Number.isFinite(budget) || budget <= 0) return;
-
- const rows = await db[PREVIEW_CACHE_TABLE].toArray();
- let totalBytes = 0;
- const entries = rows.map((row) => {
- const byteSize = toPositiveInt(
- row.byteSize,
- row.data instanceof ArrayBuffer ? row.data.byteLength : 0,
- );
- totalBytes += byteSize;
- return {
- docId: row.docId,
- byteSize,
- accessedAt: toPositiveInt(row.cachedAt, 0),
- };
- });
-
- if (totalBytes <= budget) return;
-
- entries.sort((a, b) => a.accessedAt - b.accessedAt);
- for (const entry of entries) {
- if (totalBytes <= budget) break;
- await db[PREVIEW_CACHE_TABLE].delete(entry.docId);
- totalBytes -= entry.byteSize;
- }
-}
-
-function isSha256HexId(value: string): boolean {
- return /^[a-f0-9]{64}$/i.test(value);
-}
-
-async function getMappedDocumentId(docId: string): Promise {
- if (isSha256HexId(docId)) return docId.toLowerCase();
- const row = await db[DOCUMENT_ID_MAP_TABLE].get(docId);
- return row?.id ?? docId;
-}
-
-export async function resolveDocumentId(docId: string): Promise {
- return withDB(async () => getMappedDocumentId(docId));
-}
-
-async function recordDocumentIdMapping(oldId: string, id: string): Promise {
- if (oldId === id) return;
- await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() });
-}
-
-function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map): DocumentListState {
- let didChange = false;
-
- const folders = state.folders.map((folder) => {
- let folderChanged = false;
- const seen = new Set();
- const documents: DocumentListDocument[] = [];
-
- for (const doc of folder.documents) {
- const mappedId = mapping.get(doc.id) ?? doc.id;
- if (mappedId !== doc.id) folderChanged = true;
- if (seen.has(mappedId)) {
- folderChanged = true;
- continue;
- }
- seen.add(mappedId);
- documents.push(mappedId === doc.id ? doc : { ...doc, id: mappedId });
- }
-
- if (!folderChanged) return folder;
- didChange = true;
- return { ...folder, documents };
- });
-
- return didChange ? { ...state, folders } : state;
-}
-
-async function applyDocumentIdMapping(oldId: string, newId: string): Promise {
- if (!oldId || !newId || oldId === newId) return;
- const nextId = newId.toLowerCase();
-
- await withDB(async () => {
- await db.transaction(
- 'readwrite',
- [
- db[PDF_TABLE],
- db[EPUB_TABLE],
- db[HTML_TABLE],
- db[LAST_LOCATION_TABLE],
- db[APP_CONFIG_TABLE],
- db[DOCUMENT_ID_MAP_TABLE],
- db[PREVIEW_CACHE_TABLE],
- ],
- async () => {
- await recordDocumentIdMapping(oldId, nextId);
-
- const pdf = await db[PDF_TABLE].get(oldId);
- if (pdf) {
- const existing = await db[PDF_TABLE].get(nextId);
- if (existing) {
- const merged: PDFDocument = {
- ...pdf,
- ...existing,
- id: nextId,
- folderId: existing.folderId ?? pdf.folderId,
- name: existing.name || pdf.name,
- };
- await db[PDF_TABLE].put(merged);
- await db[PDF_TABLE].delete(oldId);
- } else {
- await db[PDF_TABLE].put({ ...pdf, id: nextId });
- await db[PDF_TABLE].delete(oldId);
- }
- }
-
- const epub = await db[EPUB_TABLE].get(oldId);
- if (epub) {
- const existing = await db[EPUB_TABLE].get(nextId);
- if (existing) {
- const merged: EPUBDocument = {
- ...epub,
- ...existing,
- id: nextId,
- folderId: existing.folderId ?? epub.folderId,
- name: existing.name || epub.name,
- };
- await db[EPUB_TABLE].put(merged);
- await db[EPUB_TABLE].delete(oldId);
- } else {
- await db[EPUB_TABLE].put({ ...epub, id: nextId });
- await db[EPUB_TABLE].delete(oldId);
- }
- }
-
- const html = await db[HTML_TABLE].get(oldId);
- if (html) {
- const existing = await db[HTML_TABLE].get(nextId);
- if (existing) {
- const merged: HTMLDocument = {
- ...html,
- ...existing,
- id: nextId,
- folderId: existing.folderId ?? html.folderId,
- name: existing.name || html.name,
- };
- await db[HTML_TABLE].put(merged);
- await db[HTML_TABLE].delete(oldId);
- } else {
- await db[HTML_TABLE].put({ ...html, id: nextId });
- await db[HTML_TABLE].delete(oldId);
- }
- }
-
- const oldLocation = await db[LAST_LOCATION_TABLE].get(oldId);
- if (oldLocation) {
- const newLocation = await db[LAST_LOCATION_TABLE].get(nextId);
- if (!newLocation) {
- await db[LAST_LOCATION_TABLE].put({ docId: nextId, location: oldLocation.location });
- }
- await db[LAST_LOCATION_TABLE].delete(oldId);
- }
-
- const preview = await db[PREVIEW_CACHE_TABLE].get(oldId);
- if (preview) {
- const existing = await db[PREVIEW_CACHE_TABLE].get(nextId);
- if (!existing || Number(existing.cachedAt ?? 0) < Number(preview.cachedAt ?? 0)) {
- await db[PREVIEW_CACHE_TABLE].put({ ...preview, docId: nextId });
- }
- await db[PREVIEW_CACHE_TABLE].delete(oldId);
- }
-
- const appConfig = await db[APP_CONFIG_TABLE].get('singleton');
- if (appConfig?.documentListState) {
- const mapped = rewriteDocumentListStateDocIds(appConfig.documentListState, new Map([[oldId, nextId]]));
- if (mapped !== appConfig.documentListState) {
- await db[APP_CONFIG_TABLE].update('singleton', { documentListState: mapped });
- }
- }
- },
- );
- });
-}
-
-export async function migrateLegacyDexieDocumentIdsToSha(): Promise> {
- return withDB(async () => {
- const mappings: Array<{ oldId: string; id: string }> = [];
-
- const pdfDocs = await db[PDF_TABLE].toArray();
- for (const doc of pdfDocs) {
- if (isSha256HexId(doc.id)) continue;
- const id = await sha256HexFromBytes(new Uint8Array(doc.data));
- if (id !== doc.id) {
- mappings.push({ oldId: doc.id, id });
- await applyDocumentIdMapping(doc.id, id);
- }
- }
-
- const epubDocs = await db[EPUB_TABLE].toArray();
- for (const doc of epubDocs) {
- if (isSha256HexId(doc.id)) continue;
- const id = await sha256HexFromBytes(new Uint8Array(doc.data));
- if (id !== doc.id) {
- mappings.push({ oldId: doc.id, id });
- await applyDocumentIdMapping(doc.id, id);
- }
- }
-
- const htmlDocs = await db[HTML_TABLE].toArray();
- for (const doc of htmlDocs) {
- if (isSha256HexId(doc.id)) continue;
- const id = await sha256HexFromString(doc.data);
- if (id !== doc.id) {
- mappings.push({ oldId: doc.id, id });
- await applyDocumentIdMapping(doc.id, id);
- }
- }
-
- return mappings;
- });
-}
-
-export async function getDocumentIdMappings(): Promise> {
- return withDB(async () => {
- const rows = await db[DOCUMENT_ID_MAP_TABLE].toArray();
- return rows.map((row) => ({ oldId: row.oldId, id: row.id }));
- });
-}
-
-// PDF document helpers
-
-export async function addPdfDocument(document: PDFDocument): Promise {
- await withDB(async () => {
- console.log('Adding PDF document via Dexie:', document.name);
- const now = Date.now();
- await db[PDF_TABLE].put({
- ...document,
- cacheCreatedAt: now,
- cacheAccessedAt: now,
- cacheByteSize: document.data.byteLength,
- });
- await pruneDocumentCacheIfNeededInternal();
- });
-}
-
-export async function getPdfDocument(id: string): Promise {
- return withDB(async () => {
- console.log('Fetching PDF document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- const row = await db[PDF_TABLE].get(resolved);
- if (row) {
- await db[PDF_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
- }
- return row;
- });
-}
-
-export async function getAllPdfDocuments(): Promise {
- return withDB(async () => {
- console.log('Fetching all PDF documents via Dexie');
- return db[PDF_TABLE].toArray();
- });
-}
-
-export async function removePdfDocument(id: string): Promise {
- await withDB(async () => {
- console.log('Removing PDF document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
- await db[PDF_TABLE].delete(resolved);
- await db[LAST_LOCATION_TABLE].delete(resolved);
- await db[PREVIEW_CACHE_TABLE].delete(resolved);
- });
- });
-}
-
-export async function clearPdfDocuments(): Promise {
- await withDB(async () => {
- console.log('Clearing all PDF documents via Dexie');
- await db[PDF_TABLE].clear();
- });
-}
-
-// EPUB document helpers
-
-export async function addEpubDocument(document: EPUBDocument): Promise {
- await withDB(async () => {
- if (document.data.byteLength === 0) {
- throw new Error('Cannot store empty ArrayBuffer');
- }
-
- console.log('Adding EPUB document via Dexie:', {
- name: document.name,
- size: document.size,
- actualSize: document.data.byteLength,
- });
-
- const now = Date.now();
- await db[EPUB_TABLE].put({
- ...document,
- cacheCreatedAt: now,
- cacheAccessedAt: now,
- cacheByteSize: document.data.byteLength,
- });
- await pruneDocumentCacheIfNeededInternal();
- });
-}
-
-export async function getEpubDocument(id: string): Promise {
- return withDB(async () => {
- console.log('Fetching EPUB document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- const row = await db[EPUB_TABLE].get(resolved);
- if (row) {
- await db[EPUB_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
- }
- return row;
- });
-}
-
-export async function getAllEpubDocuments(): Promise {
- return withDB(async () => {
- console.log('Fetching all EPUB documents via Dexie');
- return db[EPUB_TABLE].toArray();
- });
-}
-
-export async function removeEpubDocument(id: string): Promise {
- await withDB(async () => {
- console.log('Removing EPUB document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
- await db[EPUB_TABLE].delete(resolved);
- await db[LAST_LOCATION_TABLE].delete(resolved);
- await db[PREVIEW_CACHE_TABLE].delete(resolved);
- });
- });
-}
-
-export async function clearEpubDocuments(): Promise {
- await withDB(async () => {
- console.log('Clearing all EPUB documents via Dexie');
- await db[EPUB_TABLE].clear();
- });
-}
-
-// HTML / text document helpers
-
-export async function addHtmlDocument(document: HTMLDocument): Promise {
- await withDB(async () => {
- console.log('Adding HTML document via Dexie:', document.name);
- const now = Date.now();
- await db[HTML_TABLE].put({
- ...document,
- cacheCreatedAt: now,
- cacheAccessedAt: now,
- cacheByteSize: cacheTextEncoder.encode(document.data).byteLength,
- });
- await pruneDocumentCacheIfNeededInternal();
- });
-}
-
-export async function getHtmlDocument(id: string): Promise {
- return withDB(async () => {
- console.log('Fetching HTML document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- const row = await db[HTML_TABLE].get(resolved);
- if (row) {
- await db[HTML_TABLE].update(resolved, { cacheAccessedAt: Date.now() });
- }
- return row;
- });
-}
-
-export async function getAllHtmlDocuments(): Promise {
- return withDB(async () => {
- console.log('Fetching all HTML documents via Dexie');
- return db[HTML_TABLE].toArray();
- });
-}
-
-export async function removeHtmlDocument(id: string): Promise {
- await withDB(async () => {
- console.log('Removing HTML document via Dexie:', id);
- const resolved = await getMappedDocumentId(id);
- await db.transaction('readwrite', db[HTML_TABLE], db[PREVIEW_CACHE_TABLE], async () => {
- await db[HTML_TABLE].delete(resolved);
- await db[PREVIEW_CACHE_TABLE].delete(resolved);
- });
- });
-}
-
-export async function clearHtmlDocuments(): Promise {
- await withDB(async () => {
- console.log('Clearing all HTML documents via Dexie');
- await db[HTML_TABLE].clear();
- });
-}
-
-export async function getDocumentRecentlyOpenedMap(): Promise> {
- return withDB(async () => {
- const byId: Record = {};
- const write = (identity: string, ts: unknown) => {
- const value = toPositiveInt(ts, 0);
- if (value <= 0) return;
- if (!byId[identity] || value > byId[identity]) byId[identity] = value;
- };
-
- await Promise.all([
- db[PDF_TABLE]
- .orderBy('cacheAccessedAt')
- .eachKey((cacheAccessedAt, cursor) => write(`pdf|${String(cursor.primaryKey)}`, cacheAccessedAt)),
- db[EPUB_TABLE]
- .orderBy('cacheAccessedAt')
- .eachKey((cacheAccessedAt, cursor) => write(`epub|${String(cursor.primaryKey)}`, cacheAccessedAt)),
- db[HTML_TABLE]
- .orderBy('cacheAccessedAt')
- .eachKey((cacheAccessedAt, cursor) => write(`html|${String(cursor.primaryKey)}`, cacheAccessedAt)),
- ]);
-
- return byId;
- });
-}
-
-export async function getAppConfig(): Promise {
- return withDB(async () => {
- const row = await db[APP_CONFIG_TABLE].get('singleton');
- return row ?? null;
- });
-}
-
-export async function updateAppConfig(partial: Partial): Promise {
- await withDB(async () => {
- const table = db[APP_CONFIG_TABLE];
- const existing = await table.get('singleton');
-
- if (!existing) {
- await table.put({
- id: 'singleton',
- ...APP_CONFIG_DEFAULTS,
- ...partial,
- });
- } else {
- await table.update('singleton', partial);
- }
- });
-}
-
-// Document list state helpers
-
-export async function saveDocumentListState(state: DocumentListState): Promise {
- await updateAppConfig({ documentListState: state });
-}
-
-export async function getDocumentListState(): Promise {
- const config = await getAppConfig();
- if (!config || !config.documentListState) return null;
- return config.documentListState;
-}
-
-// Last-location helpers (used by TTS and readers)
-
-export async function getLastDocumentLocation(docId: string): Promise {
- return withDB(async () => {
- const resolved = await getMappedDocumentId(docId);
- const row = await db[LAST_LOCATION_TABLE].get(resolved);
- return row ? row.location : null;
- });
-}
-
-export async function setLastDocumentLocation(docId: string, location: string): Promise {
- await withDB(async () => {
- const resolved = await getMappedDocumentId(docId);
- await db[LAST_LOCATION_TABLE].put({ docId: resolved, location });
- });
-}
-
-// First-visit helpers (used for onboarding/Settings modal)
-
-export async function getFirstVisit(): Promise {
- const config = await getAppConfig();
- return config?.firstVisit ?? false;
-}
-
-export async function setFirstVisit(value: boolean): Promise {
- await updateAppConfig({ firstVisit: value });
-}
-
-// Document preview cache helpers
-
-export async function getDocumentPreviewCache(docId: string): Promise {
- return withDB(async () => {
- const resolved = await getMappedDocumentId(docId);
- const row = await db[PREVIEW_CACHE_TABLE].get(resolved);
- if (row) {
- await db[PREVIEW_CACHE_TABLE].update(resolved, { cachedAt: Date.now() });
- }
- return row;
- });
-}
-
-export async function putDocumentPreviewCache(row: DocumentPreviewCacheRow): Promise {
- await withDB(async () => {
- const resolved = await getMappedDocumentId(row.docId);
- await db[PREVIEW_CACHE_TABLE].put({
- ...row,
- docId: resolved,
- cachedAt: Date.now(),
- byteSize: toPositiveInt(row.byteSize, row.data.byteLength),
- });
- await prunePreviewCacheIfNeededInternal();
- });
-}
-
-export async function removeDocumentPreviewCache(docId: string): Promise {
- await withDB(async () => {
- const resolved = await getMappedDocumentId(docId);
- await db[PREVIEW_CACHE_TABLE].delete(resolved);
- });
-}
-
-export async function clearDocumentPreviewCache(): Promise {
- await withDB(async () => {
- await db[PREVIEW_CACHE_TABLE].clear();
- });
-}
-
-// Sync helpers (server round-trip)
-
-export async function syncDocumentsToServer(
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise<{ lastSync: number }> {
- const pdfDocs = await getAllPdfDocuments();
- const epubDocs = await getAllEpubDocuments();
- const htmlDocs = await getAllHtmlDocuments();
-
- const uploads: Array<{ oldId: string; source: UploadSource }> = [];
- const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length;
- let processedDocs = 0;
-
- const textEncoder = new TextEncoder();
-
- for (const doc of pdfDocs) {
- const bytes = new Uint8Array(doc.data);
- uploads.push({
- oldId: doc.id,
- source: {
- name: doc.name,
- type: 'pdf',
- size: bytes.byteLength,
- lastModified: doc.lastModified,
- contentType: 'application/pdf',
- body: bytes,
- },
- });
- processedDocs++;
- if (onProgress) {
- onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
- }
- }
-
- for (const doc of epubDocs) {
- const bytes = new Uint8Array(doc.data);
- uploads.push({
- oldId: doc.id,
- source: {
- name: doc.name,
- type: 'epub',
- size: bytes.byteLength,
- lastModified: doc.lastModified,
- contentType: 'application/epub+zip',
- body: bytes,
- },
- });
- processedDocs++;
- if (onProgress) {
- onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
- }
- }
-
- for (const doc of htmlDocs) {
- const encoded = textEncoder.encode(doc.data);
- uploads.push({
- oldId: doc.id,
- source: {
- name: doc.name,
- type: 'html',
- size: encoded.byteLength,
- lastModified: doc.lastModified,
- contentType: 'text/plain; charset=utf-8',
- body: encoded,
- },
- });
- processedDocs++;
- if (onProgress) {
- onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
- }
- }
-
- if (onProgress) {
- onProgress(50, 'Uploading to server...');
- }
-
- const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal });
-
- for (let index = 0; index < uploads.length; index += 1) {
- const entry = uploads[index];
- const nextId = stored[index]?.id;
- if (!nextId || entry.oldId === nextId) continue;
- await applyDocumentIdMapping(entry.oldId, nextId);
- }
-
- if (onProgress) {
- onProgress(100, 'Upload complete!');
- }
-
- return { lastSync: Date.now() };
-}
-
-export async function syncSelectedDocumentsToServer(
- documents: BaseDocument[],
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise<{ lastSync: number }> {
- const uploads: Array<{ oldId: string; source: UploadSource }> = [];
- const textEncoder = new TextEncoder();
- let processed = 0;
-
- for (const doc of documents) {
- if (doc.type === 'pdf') {
- const data = await getPdfDocument(doc.id);
- if (data) {
- const bytes = new Uint8Array(data.data);
- uploads.push({
- oldId: data.id,
- source: {
- name: data.name,
- type: 'pdf',
- size: bytes.byteLength,
- lastModified: data.lastModified,
- contentType: 'application/pdf',
- body: bytes,
- },
- });
- }
- } else if (doc.type === 'epub') {
- const data = await getEpubDocument(doc.id);
- if (data) {
- const bytes = new Uint8Array(data.data);
- uploads.push({
- oldId: data.id,
- source: {
- name: data.name,
- type: 'epub',
- size: bytes.byteLength,
- lastModified: data.lastModified,
- contentType: 'application/epub+zip',
- body: bytes,
- },
- });
- }
- } else {
- const data = await getHtmlDocument(doc.id);
- if (data) {
- const bytes = textEncoder.encode(data.data);
- uploads.push({
- oldId: data.id,
- source: {
- name: data.name,
- type: 'html',
- size: bytes.byteLength,
- lastModified: data.lastModified,
- contentType: 'text/plain; charset=utf-8',
- body: bytes,
- },
- });
- }
- }
-
- processed++;
- if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`);
- }
-
- if (onProgress) onProgress(50, 'Uploading to server...');
- const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal });
-
- for (let index = 0; index < uploads.length; index += 1) {
- const entry = uploads[index];
- const nextId = stored[index]?.id;
- if (!nextId || entry.oldId === nextId) continue;
- await applyDocumentIdMapping(entry.oldId, nextId);
- }
-
- if (onProgress) {
- onProgress(100, 'Upload complete!');
- }
-
- return { lastSync: Date.now() };
-}
-
-
-export async function loadDocumentsFromServer(
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise<{ lastSync: number }> {
- if (onProgress) {
- onProgress(10, 'Starting download...');
- }
-
- const documents = await listDocuments({ signal });
- await downloadAndCacheServerDocuments(documents, onProgress, signal);
-
- if (onProgress) {
- onProgress(100, 'Load complete!');
- }
-
- return { lastSync: Date.now() };
-}
-
-export async function loadSelectedDocumentsFromServer(
- selectedIds: string[],
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise<{ lastSync: number }> {
- if (onProgress) {
- onProgress(10, 'Starting download...');
- }
-
- const documents = await listDocuments({ ids: selectedIds, signal });
- await downloadAndCacheServerDocuments(documents, onProgress, signal);
-
- if (onProgress) {
- onProgress(100, 'Load complete!');
- }
-
- return { lastSync: Date.now() };
-}
-
-async function downloadAndCacheServerDocuments(
- documents: BaseDocument[],
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-) {
- if (onProgress) onProgress(30, 'List complete');
- if (documents.length === 0) {
- if (onProgress) onProgress(95, 'No documents to import');
- return;
- }
-
- for (let i = 0; i < documents.length; i++) {
- const doc = documents[i];
- const bytes = await downloadDocumentContent(doc.id, { signal });
- await cacheStoredDocumentFromBytes(doc, bytes);
- if (onProgress) {
- onProgress(30 + ((i + 1) / documents.length) * 65, `Downloading ${i + 1}/${documents.length}: ${doc.name}`);
- }
- }
-}
-
-
-export async function importSelectedDocuments(
- documents: BaseDocument[],
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise {
- if (documents.length === 0) return;
-
- const textDecoder = new TextDecoder();
-
- for (let i = 0; i < documents.length; i++) {
- const doc = documents[i];
-
- if (onProgress) {
- onProgress(10 + (i / documents.length) * 85, `Downloading ${i + 1}/${documents.length}: ${doc.name}`);
- }
-
- const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal });
- if (!contentResponse.ok) {
- console.warn(`Failed to download library document: ${doc.name}`);
- continue;
- }
-
- const buffer = await contentResponse.arrayBuffer();
- const bytes = new Uint8Array(buffer);
-
- if (doc.type === 'pdf') {
- const localId = await sha256HexFromBytes(bytes);
- await addPdfDocument({
- id: localId,
- type: 'pdf',
- name: doc.name,
- size: bytes.byteLength,
- lastModified: doc.lastModified,
- data: buffer,
- });
- } else if (doc.type === 'epub') {
- const localId = await sha256HexFromBytes(bytes);
- await addEpubDocument({
- id: localId,
- type: 'epub',
- name: doc.name,
- size: bytes.byteLength,
- lastModified: doc.lastModified,
- data: buffer,
- });
- } else {
- const decoded = textDecoder.decode(bytes);
- const localId = await sha256HexFromString(decoded);
- await addHtmlDocument({
- id: localId,
- type: 'html',
- name: doc.name,
- size: bytes.byteLength,
- lastModified: doc.lastModified,
- data: decoded,
- });
- }
-
- if (onProgress) {
- onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`);
- }
- }
-}
-
-export async function importDocumentsFromLibrary(
- onProgress?: (progress: number, status?: string) => void,
- signal?: AbortSignal,
-): Promise {
- if (onProgress) {
- onProgress(5, 'Scanning server library...');
- }
-
- const listResponse = await fetch('/api/documents/library', { signal });
- if (!listResponse.ok) {
- throw new Error('Failed to list library documents');
- }
-
- const { documents } = (await listResponse.json()) as { documents: BaseDocument[] };
-
- if (documents.length === 0) {
- if (onProgress) {
- onProgress(100, 'No documents found in server library');
- }
- return;
- }
-
- if (onProgress) {
- onProgress(10, `Found ${documents.length} documents. Importing...`);
- }
-
- await importSelectedDocuments(documents, onProgress, signal);
-
- if (onProgress) {
- onProgress(100, 'Library import complete!');
- }
-}
diff --git a/src/lib/client/epub/location-controller.ts b/src/lib/client/epub/location-controller.ts
new file mode 100644
index 0000000..7243f18
--- /dev/null
+++ b/src/lib/client/epub/location-controller.ts
@@ -0,0 +1,22 @@
+export type EpubLocation = string | number;
+
+export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
+ return location === 'next' || location === 'prev';
+}
+
+export function shouldNavigateToDifferentCfi(
+ location: EpubLocation,
+ currentStartCfi: string | undefined,
+): location is string {
+ return typeof location === 'string'
+ && !isDirectionalEpubLocation(location)
+ && !!currentStartCfi
+ && location !== currentStartCfi;
+}
+
+export function shouldPersistEpubLocation(
+ documentId: string | undefined,
+ previousLocation: EpubLocation,
+): documentId is string {
+ return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
+}
diff --git a/src/lib/client/onboarding-flow.ts b/src/lib/client/onboarding-flow.ts
index cac1f6d..10bb6f3 100644
--- a/src/lib/client/onboarding-flow.ts
+++ b/src/lib/client/onboarding-flow.ts
@@ -1,11 +1,10 @@
-export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done';
+export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done';
export type OnboardingStepSnapshot = {
privacyRequired: boolean;
privacyAccepted: boolean;
claimEligible: boolean;
claimHasData: boolean;
- migrationRequired: boolean;
changelogPending: boolean;
};
@@ -18,10 +17,6 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb
return 'claim';
}
- if (snapshot.migrationRequired) {
- return 'migration';
- }
-
if (snapshot.changelogPending) {
return 'changelog';
}
diff --git a/src/lib/client/query-keys.ts b/src/lib/client/query-keys.ts
new file mode 100644
index 0000000..da51b0c
--- /dev/null
+++ b/src/lib/client/query-keys.ts
@@ -0,0 +1,15 @@
+export const queryKeys = {
+ documents: (sessionId: string) => ['documents', sessionId] as const,
+ document: (sessionId: string, documentId: string) => ['documents', sessionId, documentId] as const,
+ preferences: (sessionId: string) => ['preferences', sessionId] as const,
+ progress: (sessionId: string, documentId: string) => ['progress', sessionId, documentId] as const,
+ documentSettings: (sessionId: string, documentId: string) => ['document-settings', sessionId, documentId] as const,
+ onboarding: (sessionId: string) => ['onboarding', sessionId] as const,
+ folders: (sessionId: string) => ['folders', sessionId] as const,
+ audiobook: (sessionId: string, bookId: string) => ['audiobook', sessionId, bookId] as const,
+ ttsManifest: (sessionId: string, documentId: string) => ['tts-manifest', sessionId, documentId] as const,
+ parsedDocument: (sessionId: string, documentId: string) => ['parsed-document', sessionId, documentId] as const,
+ claimCounts: (sessionId: string) => ['claim-counts', sessionId] as const,
+ rateLimit: (sessionId: string) => ['rate-limit', sessionId] as const,
+ admin: (scope: string) => ['admin', scope] as const,
+};
diff --git a/src/lib/server/admin/resolve-credentials.ts b/src/lib/server/admin/resolve-credentials.ts
index 39c039f..cf4f1ca 100644
--- a/src/lib/server/admin/resolve-credentials.ts
+++ b/src/lib/server/admin/resolve-credentials.ts
@@ -8,7 +8,7 @@ import {
export interface ResolvedTtsCredentials {
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
provider: string;
- /** API key for the request. Empty string when neither admin nor user supplied one. */
+ /** Decrypted API key from the selected admin-managed provider. */
apiKey: string;
/** Base URL, or undefined to fall through to provider defaults. */
baseUrl: string | undefined;
@@ -21,62 +21,26 @@ export interface ResolvedTtsCredentials {
/**
* Resolve TTS credentials for an incoming request.
*
- * 1. If `restrictUserApiKeys` is enabled, only admin shared providers are
- * used. Built-in provider ids and user-supplied key/base headers are
- * ignored.
- * 2. If `providerHeader` matches an enabled admin provider slug, use its
- * server-stored credentials. Any `x-openai-key` / `x-openai-base-url`
- * headers from the client are ignored — admin keys must never round-trip
- * through the client.
- * 3. Otherwise, treat `providerHeader` as a built-in provider id and use the
- * per-user `x-openai-key` / `x-openai-base-url` headers as today.
+ * Only admin-managed shared providers can supply credentials. Built-in
+ * provider ids select the preferred enabled shared provider of that type.
*
* Returns `null` when the request references a slug that exists but is
* disabled — callers should reject with a 4xx.
*/
export async function resolveTtsCredentials(opts: {
providerHeader: string | null;
- apiKeyHeader: string | null;
- baseUrlHeader: string | null;
fallbackProvider?: string;
- restrictUserApiKeys?: boolean;
}): Promise {
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
- if (opts.restrictUserApiKeys) {
- const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
- const fallback = opts.fallbackProvider || '';
- const selected = await resolvePreferredEnabledAdminProvider({
- requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
- runtimeDefaultSlug: fallback,
- });
- if (!selected) {
- return { error: 'no_shared_provider_configured', slug: requestedProvider };
- }
- const apiKey = await decryptedKeyFor(selected);
- return {
- provider: selected.providerType,
- apiKey,
- baseUrl: selected.baseUrl || undefined,
- fromAdmin: true,
- adminRecord: selected,
- };
- }
-
- // Built-in provider ids are not admin slugs — short-circuit.
- if (isBuiltInProviderId(requestedProvider)) {
- return {
- provider: requestedProvider,
- apiKey: opts.apiKeyHeader || '',
- baseUrl: opts.baseUrlHeader || undefined,
- fromAdmin: false,
- };
- }
-
- // Not a built-in id → try to look it up as an admin slug.
- const admin = await getEnabledAdminProviderBySlug(requestedProvider);
+ const admin = isBuiltInProviderId(requestedProvider)
+ ? await resolvePreferredEnabledAdminProvider({
+ requestedSlug: null,
+ runtimeDefaultSlug: opts.fallbackProvider || '',
+ })
+ : await getEnabledAdminProviderBySlug(requestedProvider);
if (!admin) {
- return { error: 'provider_unknown', slug: requestedProvider };
+ return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(admin);
diff --git a/src/lib/server/user/claim-data.ts b/src/lib/server/user/claim-data.ts
index 259425e..64d2b0b 100644
--- a/src/lib/server/user/claim-data.ts
+++ b/src/lib/server/user/claim-data.ts
@@ -8,6 +8,8 @@ import {
ttsSegmentVariants,
userPreferences,
userDocumentProgress,
+ userFolders,
+ userOnboarding,
} from '@openreader/database/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
@@ -115,7 +117,7 @@ export async function claimAnonymousData(
options?: { cleanupLegacySources?: boolean },
) {
if (!userId) {
- return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 };
+ return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0, folders: 0, onboarding: 0 };
}
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
@@ -129,13 +131,16 @@ export async function claimAnonymousData(
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise>,
]);
- const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
+ const foldersClaimed = await transferUserFolders(unclaimedUserId, userId);
+ const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed, onboardingClaimed] = await Promise.all([
transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId),
transferUserDocumentSettings(unclaimedUserId, userId),
+ transferUserOnboarding(unclaimedUserId, userId),
]);
+ await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId));
if (
options?.cleanupLegacySources !== false
@@ -168,9 +173,30 @@ export async function claimAnonymousData(
preferences: preferencesClaimed,
progress: progressClaimed,
documentSettings: documentSettingsClaimed,
+ folders: foldersClaimed,
+ onboarding: onboardingClaimed,
};
}
+export async function transferUserFolders(fromUserId: string, toUserId: string): Promise {
+ if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
+ const rows = await db.select().from(userFolders).where(eq(userFolders.userId, fromUserId));
+ for (const row of rows) {
+ await db.insert(userFolders).values({ ...row, userId: toUserId }).onConflictDoNothing();
+ }
+ return rows.length;
+}
+
+export async function transferUserOnboarding(fromUserId: string, toUserId: string): Promise {
+ if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
+ const rows = await db.select().from(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
+ const row = rows[0];
+ if (!row) return 0;
+ await db.insert(userOnboarding).values({ ...row, userId: toUserId }).onConflictDoNothing();
+ await db.delete(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
+ return 1;
+}
+
/**
* Transfer documents from one userId to another.
*
diff --git a/src/lib/server/user/data-export.ts b/src/lib/server/user/data-export.ts
index 8e173f3..3a5734c 100644
--- a/src/lib/server/user/data-export.ts
+++ b/src/lib/server/user/data-export.ts
@@ -61,6 +61,8 @@ export type AppendUserExportArchiveInput = {
exportedAtMs: number;
profileData: unknown;
preferences: unknown | null;
+ folders?: unknown[];
+ onboarding?: unknown | null;
readingHistory: unknown[];
ttsUsage: unknown[];
jobEvents: unknown[];
@@ -144,6 +146,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
exportedAtMs,
profileData,
preferences,
+ folders = [],
+ onboarding = null,
readingHistory,
ttsUsage,
jobEvents,
@@ -171,6 +175,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
if (preferences) {
appendJson(archive, 'preferences.json', preferences);
}
+ appendJson(archive, 'folders.json', folders);
+ if (onboarding) appendJson(archive, 'onboarding.json', onboarding);
appendJson(archive, 'reading_history.json', readingHistory);
appendJson(archive, 'tts_usage.json', ttsUsage);
appendJson(archive, 'job_events.json', jobEvents);
diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts
index 51d0a4e..3ed441b 100644
--- a/src/lib/server/user/preferences-normalize.ts
+++ b/src/lib/server/user/preferences-normalize.ts
@@ -125,6 +125,11 @@ export function sanitizePreferencesPatch(
case 'savedVoices':
out[key] = sanitizeSavedVoices(value);
break;
+ case 'documentListState':
+ if (isRecord(value)) {
+ out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key];
+ }
+ break;
default:
break;
}
diff --git a/src/lib/server/user/preferences-payload.ts b/src/lib/server/user/preferences-payload.ts
deleted file mode 100644
index d6f750b..0000000
--- a/src/lib/server/user/preferences-payload.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { normalizeVersion } from '@/lib/shared/changelog';
-
-export const USER_PREFERENCES_META_KEY = '_meta';
-export const USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY = 'lastSeenAppVersion';
-
-export type UserPreferencesMeta = Record & {
- lastSeenAppVersion?: string;
-};
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null;
-}
-
-export function deserializeUserPreferencesPayload(value: unknown): Record {
- if (typeof value === 'string') {
- try {
- const parsed = JSON.parse(value);
- return isRecord(parsed) ? parsed : {};
- } catch {
- return {};
- }
- }
- return isRecord(value) ? value : {};
-}
-
-export function extractUserPreferencesMeta(payload: unknown): UserPreferencesMeta {
- const record = deserializeUserPreferencesPayload(payload);
- const rawMeta = record[USER_PREFERENCES_META_KEY];
- if (!isRecord(rawMeta)) return {};
-
- const out: UserPreferencesMeta = { ...rawMeta };
- const rawLastSeen = out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
- if (typeof rawLastSeen === 'string') {
- out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY] = normalizeVersion(rawLastSeen);
- } else {
- delete out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
- }
-
- return out;
-}
-
-export function withUserPreferencesMeta(
- payload: Record,
- meta: UserPreferencesMeta,
-): Record {
- const out = { ...payload };
- delete out[USER_PREFERENCES_META_KEY];
- if (Object.keys(meta).length > 0) {
- out[USER_PREFERENCES_META_KEY] = meta;
- }
- return out;
-}
diff --git a/src/lib/shared/onboarding-state.ts b/src/lib/shared/onboarding-state.ts
deleted file mode 100644
index 8b8723d..0000000
--- a/src/lib/shared/onboarding-state.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-export type OnboardingStorageScope = 'local-dexie' | 'server-user-preferences' | 'hybrid';
-
-export type OnboardingStateDescriptor = {
- /**
- * Where this state is persisted today.
- * - local-dexie: browser/device-local only
- * - server-user-preferences: per-user on server
- * - hybrid: intentionally persisted in both places
- */
- scope: OnboardingStorageScope;
- /**
- * Optional local key name when state is stored in Dexie app-config.
- */
- localKey?: string;
- /**
- * Optional server-side key when state is stored in user preferences/meta.
- */
- serverKey?: string;
-};
-
-/**
- * Central registry for onboarding-related state and where each item lives.
- * Keep this map up to date whenever onboarding persistence changes.
- */
-export const ONBOARDING_STATE_REGISTRY = {
- privacyAccepted: {
- scope: 'local-dexie',
- localKey: 'privacyAccepted',
- },
- firstVisitSettingsOpened: {
- scope: 'local-dexie',
- localKey: 'firstVisit',
- },
- documentsMigrationPrompted: {
- scope: 'local-dexie',
- localKey: 'documentsMigrationPrompted',
- },
- changelogLastSeenAppVersion: {
- scope: 'server-user-preferences',
- serverKey: '_meta.lastSeenAppVersion',
- },
-} as const satisfies Record;
-
-export type OnboardingStateKey = keyof typeof ONBOARDING_STATE_REGISTRY;
-
diff --git a/src/types/config.ts b/src/types/config.ts
index 711b634..858e71c 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -29,8 +29,6 @@ export function clampTtsSegmentMaxBlockLength(value: number | undefined | null):
}
export interface AppConfigValues {
- apiKey: string;
- baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
@@ -55,10 +53,7 @@ export interface AppConfigValues {
epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean;
- firstVisit: boolean;
documentListState: DocumentListState;
- privacyAccepted: boolean;
- documentsMigrationPrompted: boolean;
}
/**
@@ -75,8 +70,6 @@ export function getAppConfigDefaults(): AppConfigValues {
// provider id (the old 'custom-openai') into every user's config, since that
// value isn't actually selectable in shared-provider mode.
return {
- apiKey: '',
- baseUrl: '',
viewType: 'single',
voiceSpeed: 1,
audioPlayerSpeed: 1,
@@ -101,7 +94,6 @@ export function getAppConfigDefaults(): AppConfigValues {
epubWordHighlightEnabled: true,
htmlHighlightEnabled: true,
htmlWordHighlightEnabled: true,
- firstVisit: false,
documentListState: {
sortBy: 'name',
sortDirection: 'asc',
@@ -110,8 +102,6 @@ export function getAppConfigDefaults(): AppConfigValues {
showHint: true,
viewMode: 'grid',
},
- privacyAccepted: false,
- documentsMigrationPrompted: false,
};
}
diff --git a/src/types/documents.ts b/src/types/documents.ts
index 98802b5..b9914f2 100644
--- a/src/types/documents.ts
+++ b/src/types/documents.ts
@@ -6,6 +6,7 @@ export interface BaseDocument {
size: number;
lastModified: number;
recentlyOpenedAt?: number;
+ contentVersion?: string;
type: DocumentType;
scope?: 'user';
folderId?: string;
diff --git a/src/types/user-state.ts b/src/types/user-state.ts
index 3fc796e..0ae9704 100644
--- a/src/types/user-state.ts
+++ b/src/types/user-state.ts
@@ -25,6 +25,7 @@ export const SYNCED_PREFERENCE_KEYS = [
'epubWordHighlightEnabled',
'htmlHighlightEnabled',
'htmlWordHighlightEnabled',
+ 'documentListState',
] as const;
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts
index 47035a2..7c44794 100644
--- a/tests/folders.spec.ts
+++ b/tests/folders.spec.ts
@@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => {
// Hint should disappear
await expect(hint).toHaveCount(0);
- // Ensure the dismissal has been persisted to IndexedDB before reloading
+ // Ensure the dismissal has been persisted to server preferences before reloading
await waitForDocumentListHintPersist(page, false);
// Reload and ensure it remains dismissed
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 9d280f7..9cefaff 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -533,27 +533,14 @@ export async function triggerViewportResize(page: Page, width: number, height: n
await page.setViewportSize({ width, height });
}
-// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
+// Wait for DocumentListState.showHint to persist in server preferences.
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
await page.waitForFunction(async (exp) => {
try {
- const openDb = () => new Promise((resolve, reject) => {
- const req = indexedDB.open('openreader-db');
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
- const db = await openDb();
- const readConfig = () => new Promise((resolve, reject) => {
- const tx = db.transaction(['app-config'], 'readonly');
- const store = tx.objectStore('app-config');
- const getReq = store.get('singleton');
- getReq.onsuccess = () => resolve(getReq.result);
- getReq.onerror = () => reject(getReq.error);
- });
- const item = await readConfig();
- db.close();
- if (!item || typeof item !== 'object') return false;
- const state = (item as { documentListState?: unknown }).documentListState;
+ const response = await fetch('/api/user/state/preferences', { cache: 'no-store' });
+ if (!response.ok) return false;
+ const item = await response.json() as { preferences?: { documentListState?: unknown } };
+ const state = item.preferences?.documentListState;
if (!state || typeof state !== 'object') return false;
const showHint = (state as { showHint?: unknown }).showHint;
return typeof showHint === 'boolean' && showHint === exp;
diff --git a/tests/unit/blob-cache.vitest.spec.ts b/tests/unit/blob-cache.vitest.spec.ts
new file mode 100644
index 0000000..7b50c85
--- /dev/null
+++ b/tests/unit/blob-cache.vitest.spec.ts
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import {
+ audioBlobCacheKey,
+ documentBlobCacheKey,
+ getCachedBlob,
+ previewBlobCacheKey,
+} from '../../src/lib/client/cache/blob-cache';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('blob cache stable keys', () => {
+ test('builds versioned synthetic keys', () => {
+ expect(documentBlobCacheKey('doc/id', 'v1')).toBe('/openreader-cache/documents/doc%2Fid/v1');
+ expect(previewBlobCacheKey('doc', 'etag/1')).toBe('/openreader-cache/previews/doc/etag%2F1');
+ expect(audioBlobCacheKey('audio/key', 2)).toBe('/openreader-cache/audio/audio%2Fkey/2');
+ });
+
+ test('returns a cache hit without fetching', async () => {
+ const cached = new Response('cached');
+ const match = vi.fn().mockResolvedValue(cached);
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ match, put: vi.fn() }) });
+ const fetchSource = vi.fn();
+
+ expect(await (await getCachedBlob('/openreader-cache/documents/doc/v1', fetchSource)).text()).toBe('cached');
+ expect(fetchSource).not.toHaveBeenCalled();
+ });
+
+ test('fetches and caches a successful full response on a miss', async () => {
+ const put = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('caches', {
+ open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
+ });
+
+ const response = await getCachedBlob('/openreader-cache/documents/doc/v2', async () => new Response('network'));
+ expect(await response.text()).toBe('network');
+ expect(put).toHaveBeenCalledOnce();
+ });
+
+ test('treats missing Cache Storage and cache write failures as non-fatal', async () => {
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('caches', {
+ open: vi.fn()
+ .mockRejectedValueOnce(new Error('unavailable'))
+ .mockResolvedValueOnce({
+ match: vi.fn().mockResolvedValue(undefined),
+ put: vi.fn().mockRejectedValue(new Error('quota')),
+ }),
+ });
+
+ expect(await (await getCachedBlob('/openreader-cache/documents/doc/v3', async () => new Response('first'))).text()).toBe('first');
+ expect(await (await getCachedBlob('/openreader-cache/documents/doc/v4', async () => new Response('second'))).text()).toBe('second');
+ });
+
+ test('does not cache partial responses and throws for failed fetches', async () => {
+ const put = vi.fn();
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('caches', {
+ open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
+ });
+
+ const partial = await getCachedBlob(
+ '/openreader-cache/audio/key/v1',
+ async () => new Response('partial', { status: 206, headers: { 'Content-Range': 'bytes 0-6/20' } }),
+ );
+ expect(partial.status).toBe(206);
+ expect(put).not.toHaveBeenCalled();
+ await expect(getCachedBlob('/openreader-cache/audio/key/v2', async () => new Response(null, { status: 404 })))
+ .rejects.toThrow('Blob fetch failed: 404');
+ });
+});
diff --git a/tests/unit/document-cache.vitest.spec.ts b/tests/unit/document-cache.vitest.spec.ts
deleted file mode 100644
index c807c39..0000000
--- a/tests/unit/document-cache.vitest.spec.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { describe, expect, test } from 'vitest';
-import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents';
-import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents';
-import { makeBaseDocument } from './support/document-fixtures';
-
-describe('document-cache-core', () => {
- test('returns cached PDF without downloading', async () => {
- const meta: BaseDocument = makeBaseDocument({
- id: 'pdf1',
- name: 'a.pdf',
- size: 10,
- lastModified: 1_700_000_000_001,
- type: 'pdf',
- });
-
- let downloads = 0;
- const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) };
-
- const result = await ensureCachedDocumentCore(meta, {
- get: async () => cached,
- putPdf: async () => { throw new Error('should not put'); },
- putEpub: async () => { throw new Error('should not put'); },
- putHtml: async () => { throw new Error('should not put'); },
- download: async () => {
- downloads++;
- return new ArrayBuffer(0);
- },
- decodeText: () => '',
- });
-
- expect(downloads).toBe(0);
- expect(result.type).toBe('pdf');
- });
-
- test('downloads and stores on cache miss (EPUB)', async () => {
- const meta: BaseDocument = makeBaseDocument({
- id: 'epub1',
- name: 'b.epub',
- size: 10,
- lastModified: 1_700_000_000_002,
- type: 'epub',
- });
-
- const store = new Map();
- let downloads = 0;
-
- const result = await ensureCachedDocumentCore(meta, {
- get: async (m) => store.get(m.id) ?? null,
- putPdf: async () => { /* unused */ },
- putEpub: async (m, data) => {
- store.set(m.id, { ...m, type: 'epub', data } as EPUBDocument);
- },
- putHtml: async () => { /* unused */ },
- download: async () => {
- downloads++;
- return new Uint8Array([1, 2, 3]).buffer;
- },
- decodeText: () => '',
- });
-
- expect(downloads).toBe(1);
- expect(result.type).toBe('epub');
- expect((result as EPUBDocument).data.byteLength).toBe(3);
- });
-
- test('downloads, decodes, and stores HTML on cache miss', async () => {
- const meta: BaseDocument = makeBaseDocument({
- id: 'html1',
- name: 'c.txt',
- size: 5,
- lastModified: 1_700_000_000_003,
- type: 'html',
- });
-
- const store = new Map();
- let decodedCalls = 0;
-
- const result = await ensureCachedDocumentCore(meta, {
- get: async (m) => store.get(m.id) ?? null,
- putPdf: async () => { /* unused */ },
- putEpub: async () => { /* unused */ },
- putHtml: async (m, data) => {
- store.set(m.id, { ...m, type: 'html', data } as HTMLDocument);
- },
- download: async () => new TextEncoder().encode('hello').buffer,
- decodeText: (buf) => {
- decodedCalls++;
- return new TextDecoder().decode(new Uint8Array(buf));
- },
- });
-
- expect(decodedCalls).toBe(1);
- expect(result.type).toBe('html');
- expect((result as HTMLDocument).data).toBe('hello');
- });
-
- test('throws deterministic cache error when download succeeds but backend misses persisted PDF', async () => {
- const meta = makeBaseDocument({
- id: 'pdf-cache-miss',
- type: 'pdf',
- });
-
- await expect(
- ensureCachedDocumentCore(meta, {
- get: async () => null,
- putPdf: async () => { /* simulate write path without persisted row */ },
- putEpub: async () => { /* unused */ },
- putHtml: async () => { /* unused */ },
- download: async () => new Uint8Array([1, 2, 3]).buffer,
- decodeText: () => '',
- }),
- ).rejects.toThrow('Failed to cache PDF');
- });
-});
diff --git a/tests/unit/epub-location-controller.vitest.spec.ts b/tests/unit/epub-location-controller.vitest.spec.ts
index c784eab..df5006c 100644
--- a/tests/unit/epub-location-controller.vitest.spec.ts
+++ b/tests/unit/epub-location-controller.vitest.spec.ts
@@ -4,7 +4,7 @@ import {
isDirectionalEpubLocation,
shouldNavigateToDifferentCfi,
shouldPersistEpubLocation,
-} from '../../src/hooks/epub/useEPUBLocationController';
+} from '../../src/lib/client/epub/location-controller';
describe('EPUB location controller helpers', () => {
test('detects directional locations', () => {
diff --git a/tests/unit/onboarding-flow.vitest.spec.ts b/tests/unit/onboarding-flow.vitest.spec.ts
index 8569171..a51edaa 100644
--- a/tests/unit/onboarding-flow.vitest.spec.ts
+++ b/tests/unit/onboarding-flow.vitest.spec.ts
@@ -9,7 +9,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false,
claimEligible: true,
claimHasData: true,
- migrationRequired: true,
changelogPending: true,
});
@@ -22,33 +21,18 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true,
claimEligible: true,
claimHasData: true,
- migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('claim');
});
- test('resolves migration when no claim is needed', () => {
- const step = resolveNextOnboardingStep({
- privacyRequired: true,
- privacyAccepted: true,
- claimEligible: true,
- claimHasData: false,
- migrationRequired: true,
- changelogPending: true,
- });
-
- expect(step).toBe('migration');
- });
-
test('resolves changelog when prior steps are clear', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
- migrationRequired: false,
changelogPending: true,
});
@@ -61,7 +45,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
- migrationRequired: false,
changelogPending: false,
});
const minimalStep = resolveNextOnboardingStep({
@@ -69,7 +52,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false,
claimEligible: false,
claimHasData: false,
- migrationRequired: false,
changelogPending: false,
});
diff --git a/tests/unit/onboarding-state.vitest.spec.ts b/tests/unit/onboarding-state.vitest.spec.ts
deleted file mode 100644
index c457b92..0000000
--- a/tests/unit/onboarding-state.vitest.spec.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { describe, expect, test } from 'vitest';
-import { ONBOARDING_STATE_REGISTRY } from '../../src/lib/shared/onboarding-state';
-import { SYNCED_PREFERENCE_KEYS } from '../../src/types/user-state';
-
-describe('onboarding state storage scopes', () => {
- test('keeps local onboarding flags out of synced server preferences', () => {
- expect(ONBOARDING_STATE_REGISTRY.privacyAccepted.scope).toBe('local-dexie');
- expect(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.scope).toBe('local-dexie');
- expect(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.scope).toBe('local-dexie');
- expect(ONBOARDING_STATE_REGISTRY.changelogLastSeenAppVersion.scope).toBe('server-user-preferences');
-
- const synced = new Set(SYNCED_PREFERENCE_KEYS);
-
- expect(synced.has(ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey!)).toBe(false);
- expect(synced.has(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey!)).toBe(false);
- expect(synced.has(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.localKey!)).toBe(false);
- });
-});
-
diff --git a/tests/unit/query-keys.vitest.spec.ts b/tests/unit/query-keys.vitest.spec.ts
new file mode 100644
index 0000000..4c80508
--- /dev/null
+++ b/tests/unit/query-keys.vitest.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from 'vitest';
+import { queryKeys } from '../../src/lib/client/query-keys';
+
+describe('query keys', () => {
+ test('isolates server state by session and document', () => {
+ expect(queryKeys.documents('user-a')).not.toEqual(queryKeys.documents('user-b'));
+ expect(queryKeys.progress('user-a', 'doc-a')).not.toEqual(queryKeys.progress('user-a', 'doc-b'));
+ expect(queryKeys.documentSettings('user-a', 'doc-a')).not.toEqual(queryKeys.documentSettings('user-b', 'doc-a'));
+ });
+
+ test('defines centralized keys for migrated server-state domains', () => {
+ expect(queryKeys.preferences('user')).toEqual(['preferences', 'user']);
+ expect(queryKeys.onboarding('user')).toEqual(['onboarding', 'user']);
+ expect(queryKeys.folders('user')).toEqual(['folders', 'user']);
+ expect(queryKeys.audiobook('user', 'book')).toEqual(['audiobook', 'user', 'book']);
+ expect(queryKeys.ttsManifest('user', 'doc')).toEqual(['tts-manifest', 'user', 'doc']);
+ expect(queryKeys.parsedDocument('user', 'doc')).toEqual(['parsed-document', 'user', 'doc']);
+ });
+});
diff --git a/tests/unit/transfer-user-documents.vitest.spec.ts b/tests/unit/transfer-user-documents.vitest.spec.ts
index 11c93d7..ad723d1 100644
--- a/tests/unit/transfer-user-documents.vitest.spec.ts
+++ b/tests/unit/transfer-user-documents.vitest.spec.ts
@@ -25,6 +25,8 @@ describe('transferUserDocuments', () => {
size INTEGER NOT NULL,
last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL,
+ folder_id TEXT,
+ recently_opened_at INTEGER,
parse_state TEXT,
parsed_json_key TEXT,
created_at INTEGER,
@@ -149,6 +151,8 @@ describe('transferUserDocuments', () => {
size INTEGER NOT NULL,
last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL,
+ folder_id TEXT,
+ recently_opened_at INTEGER,
parse_state TEXT,
parsed_json_key TEXT,
created_at INTEGER,
diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts
index 3725bee..e848fed 100644
--- a/tests/unit/tts-provider-catalog.vitest.spec.ts
+++ b/tests/unit/tts-provider-catalog.vitest.spec.ts
@@ -533,7 +533,6 @@ describe('config helpers', () => {
test('builds synced preference patches and honors non-default filtering', () => {
expect(buildSyncedPreferencePatch({
voiceSpeed: 1.2,
- baseUrl: 'http://localhost',
ttsModel: 'kokoro',
})).toEqual({
voiceSpeed: 1.2,
diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts
deleted file mode 100644
index aae3c76..0000000
--- a/tests/unit/user-preferences-sync.vitest.spec.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-
-import {
- cancelPendingPreferenceSync,
- flushUserPreferencesSync,
- scheduleUserPreferencesSync,
-} from '../../src/lib/client/api/user-state';
-
-type FetchMock = ReturnType & { calls: Array<{ url: string; init?: RequestInit }> };
-
-function installFetchMock(responder: () => Response): FetchMock {
- const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- mock.calls.push({ url: String(input), init });
- return responder();
- }) as unknown as FetchMock;
- mock.calls = [];
- global.fetch = mock as unknown as typeof fetch;
- return mock;
-}
-
-function okResponse(): Response {
- return new Response(
- JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }),
- { status: 200, headers: { 'Content-Type': 'application/json' } },
- );
-}
-
-describe('flushUserPreferencesSync', () => {
- const originalFetch = global.fetch;
-
- beforeEach(() => {
- vi.useFakeTimers();
- cancelPendingPreferenceSync();
- });
-
- afterEach(() => {
- cancelPendingPreferenceSync();
- vi.useRealTimers();
- global.fetch = originalFetch;
- });
-
- test('sends a pending debounced patch immediately instead of waiting for the timer', async () => {
- const fetchMock = installFetchMock(okResponse);
-
- scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1');
-
- // Nothing should have fired yet — it's still debounced.
- expect(fetchMock.calls).toHaveLength(0);
-
- await flushUserPreferencesSync();
-
- expect(fetchMock.calls).toHaveLength(1);
- const { url, init } = fetchMock.calls[0];
- expect(url).toContain('/api/user/state/preferences');
- expect(init?.method).toBe('PUT');
- const body = JSON.parse(String(init?.body));
- expect(body.patch.providerRef).toBe('shared-b');
- expect(body.patch.providerType).toBe('openai');
-
- // The debounce timer must not also fire a duplicate request afterwards.
- await vi.runAllTimersAsync();
- expect(fetchMock.calls).toHaveLength(1);
- });
-
- test('is a no-op when there is no pending patch', async () => {
- const fetchMock = installFetchMock(okResponse);
- await flushUserPreferencesSync();
- expect(fetchMock.calls).toHaveLength(0);
- });
-
- test('rejects when the server write fails so callers can surface the error', async () => {
- installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }));
-
- scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1');
-
- await expect(flushUserPreferencesSync()).rejects.toThrow();
- });
-});
diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts
index dad3ae0..e2701e1 100644
--- a/tests/upload.spec.ts
+++ b/tests/upload.spec.ts
@@ -1,14 +1,9 @@
import { test, expect } from '@playwright/test';
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
-interface HtmlDocumentRow {
- id?: string;
- data?: string;
-}
-
type HashCheckResult =
| { ok: true; storedId: string; computedId: string }
- | { ok: false; reason: 'Missing stored html document' | 'Hash mismatch'; storedId?: string; computedId?: string };
+ | { ok: false; reason: 'Missing stored html document' | 'Hash mismatch' | 'Content fetch failed'; storedId?: string; computedId?: string };
test.describe('Document Upload Tests', () => {
test.beforeEach(async ({ page }, testInfo) => {
@@ -58,38 +53,24 @@ test.describe('Document Upload Tests', () => {
await expectDocumentListed(page, 'sample.txt');
const result = await page.evaluate(async () => {
- const idb = await new Promise((resolve, reject) => {
- const request = indexedDB.open('openreader-db');
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result);
- });
+ const listRes = await fetch('/api/documents', { cache: 'no-store' });
+ const docs = listRes.ok
+ ? ((await listRes.json()) as { documents?: Array<{ id: string; name: string }> }).documents ?? []
+ : [];
+ const doc = docs.find((item) => item.name === 'sample.txt');
+ if (!doc?.id) return { ok: false, reason: 'Missing stored html document' as const };
- try {
- const docs = await new Promise((resolve, reject) => {
- const tx = idb.transaction('html-documents', 'readonly');
- const store = tx.objectStore('html-documents');
- const request = store.getAll();
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]);
- });
+ const contentRes = await fetch(`/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`, { cache: 'no-store' });
+ if (!contentRes.ok) return { ok: false, reason: 'Content fetch failed' as const, storedId: doc.id };
+ const digest = await crypto.subtle.digest('SHA-256', await contentRes.arrayBuffer());
+ const computedId = Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
- if (!docs[0]?.data || !docs[0]?.id) {
- return { ok: false, reason: 'Missing stored html document' as const };
- }
-
- const bytes = new TextEncoder().encode(String(docs[0].data));
- const digest = await crypto.subtle.digest('SHA-256', bytes);
- const computedId = Array.from(new Uint8Array(digest))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-
- if (computedId === docs[0].id) {
- return { ok: true as const, storedId: docs[0].id as string, computedId };
- }
- return { ok: false as const, reason: 'Hash mismatch', storedId: docs[0].id as string, computedId };
- } finally {
- idb.close();
+ if (computedId === doc.id) {
+ return { ok: true as const, storedId: doc.id, computedId };
}
+ return { ok: false as const, reason: 'Hash mismatch', storedId: doc.id, computedId };
});
const detail = result.ok