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