From 76553023e8b8f3ac7ebae90338013521a04bfe2d Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 16:37:28 -0600 Subject: [PATCH] refactor(user): overhaul user data cleanup and export for cascading deletes and TTS segment support Revise user data cleanup logic to ensure proper cascading deletion of user-related database rows and S3 objects, including shared document and preview artifacts. Introduce explicit checks for last ownership before removing shared resources. Add TTS segment cache and audio cleanup to document and user deletion flows. Expand user data export to include TTS segment entries and audio, job events, document settings, and linked auth sessions. Update schema with ON DELETE CASCADE for userTtsChars and userJobEvents. Add new migration scripts and comprehensive unit tests for cleanup and export scenarios. --- .../0010_user-data-cleanup-cascades.sql | 8 + drizzle/postgres/meta/0010_snapshot.json | 1922 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + .../0010_user-data-cleanup-cascades.sql | 35 + drizzle/sqlite/meta/0010_snapshot.json | 1765 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + src/app/api/account/delete/route.ts | 19 +- .../api/documents/blob/get/fallback/route.ts | 8 +- .../documents/blob/preview/fallback/route.ts | 8 +- src/app/api/documents/route.ts | 20 + src/app/api/user/export/route.ts | 110 +- src/db/schema_postgres.ts | 4 +- src/db/schema_sqlite.ts | 4 +- src/lib/server/audiobooks/blobstore.ts | 6 +- src/lib/server/auth/auth.ts | 5 +- src/lib/server/documents/blobstore.ts | 14 +- src/lib/server/documents/register-upload.ts | 19 + src/lib/server/tts/segments-blobstore.ts | 7 +- src/lib/server/tts/segments-cache.ts | 39 +- src/lib/server/tts/segments.ts | 11 + src/lib/server/user/data-cleanup.ts | 107 +- src/lib/server/user/data-export.ts | 83 +- tests/unit/data-export.vitest.spec.ts | 63 + .../storage-prefix-cleanup.vitest.spec.ts | 50 + .../tts-segments-blobstore.vitest.spec.ts | 45 + tests/unit/tts-segments.vitest.spec.ts | 10 + tests/unit/user-data-cleanup.vitest.spec.ts | 115 + 27 files changed, 4425 insertions(+), 66 deletions(-) create mode 100644 drizzle/postgres/0010_user-data-cleanup-cascades.sql create mode 100644 drizzle/postgres/meta/0010_snapshot.json create mode 100644 drizzle/sqlite/0010_user-data-cleanup-cascades.sql create mode 100644 drizzle/sqlite/meta/0010_snapshot.json create mode 100644 tests/unit/data-export.vitest.spec.ts create mode 100644 tests/unit/storage-prefix-cleanup.vitest.spec.ts create mode 100644 tests/unit/tts-segments-blobstore.vitest.spec.ts create mode 100644 tests/unit/user-data-cleanup.vitest.spec.ts diff --git a/drizzle/postgres/0010_user-data-cleanup-cascades.sql b/drizzle/postgres/0010_user-data-cleanup-cascades.sql new file mode 100644 index 0000000..f0e8dfd --- /dev/null +++ b/drizzle/postgres/0010_user-data-cleanup-cascades.sql @@ -0,0 +1,8 @@ +DELETE FROM "user_job_events" WHERE NOT EXISTS ( + SELECT 1 FROM "user" WHERE "user"."id" = "user_job_events"."user_id" +);--> statement-breakpoint +DELETE FROM "user_tts_chars" WHERE NOT EXISTS ( + SELECT 1 FROM "user" WHERE "user"."id" = "user_tts_chars"."user_id" +);--> statement-breakpoint +ALTER TABLE "user_job_events" ADD CONSTRAINT "user_job_events_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_tts_chars" ADD CONSTRAINT "user_tts_chars_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/postgres/meta/0010_snapshot.json b/drizzle/postgres/meta/0010_snapshot.json new file mode 100644 index 0000000..806434e --- /dev/null +++ b/drizzle/postgres/meta/0010_snapshot.json @@ -0,0 +1,1922 @@ +{ + "id": "7aaaea38-619e-42d8-b683-6231e6221d33", + "prevId": "23b90a4b-fb63-4f9a-9230-09ca218d0b78", + "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_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 + }, + "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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_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_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": { + "user_tts_chars_user_id_user_id_fk": { + "name": "user_tts_chars_user_id_user_id_fk", + "tableFrom": "user_tts_chars", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index a2a3318..a93c07e 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1780625663880, "tag": "0009_drop_pdf_parse", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1780785284335, + "tag": "0010_user-data-cleanup-cascades", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0010_user-data-cleanup-cascades.sql b/drizzle/sqlite/0010_user-data-cleanup-cascades.sql new file mode 100644 index 0000000..b891cd9 --- /dev/null +++ b/drizzle/sqlite/0010_user-data-cleanup-cascades.sql @@ -0,0 +1,35 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +DELETE FROM `user_job_events` WHERE NOT EXISTS ( + SELECT 1 FROM `user` WHERE `user`.`id` = `user_job_events`.`user_id` +);--> statement-breakpoint +DELETE FROM `user_tts_chars` WHERE NOT EXISTS ( + SELECT 1 FROM `user` WHERE `user`.`id` = `user_tts_chars`.`user_id` +);--> statement-breakpoint +CREATE TABLE `__new_user_job_events` ( + `user_id` text NOT NULL, + `action` text NOT NULL, + `op_id` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + PRIMARY KEY(`user_id`, `action`, `op_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_user_job_events`("user_id", "action", "op_id", "created_at") SELECT "user_id", "action", "op_id", "created_at" FROM `user_job_events`;--> statement-breakpoint +DROP TABLE `user_job_events`;--> statement-breakpoint +ALTER TABLE `__new_user_job_events` RENAME TO `user_job_events`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `idx_user_job_events_user_action_created` ON `user_job_events` (`user_id`,`action`,`created_at`);--> statement-breakpoint +CREATE TABLE `__new_user_tts_chars` ( + `user_id` text NOT NULL, + `date` text NOT NULL, + `char_count` integer DEFAULT 0, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + PRIMARY KEY(`user_id`, `date`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_user_tts_chars`("user_id", "date", "char_count", "created_at", "updated_at") SELECT "user_id", "date", "char_count", "created_at", "updated_at" FROM `user_tts_chars`;--> statement-breakpoint +DROP TABLE `user_tts_chars`;--> statement-breakpoint +ALTER TABLE `__new_user_tts_chars` RENAME TO `user_tts_chars`;--> statement-breakpoint +CREATE INDEX `idx_user_tts_chars_date` ON `user_tts_chars` (`date`); diff --git a/drizzle/sqlite/meta/0010_snapshot.json b/drizzle/sqlite/meta/0010_snapshot.json new file mode 100644 index 0000000..9815696 --- /dev/null +++ b/drizzle/sqlite/meta/0010_snapshot.json @@ -0,0 +1,1765 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6f0bbd3d-f8aa-49d1-8565-853f2b2654cb", + "prevId": "2c23f770-269c-4cae-a71e-4ef88355681f", + "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_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 + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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_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_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": { + "user_tts_chars_user_id_user_id_fk": { + "name": "user_tts_chars_user_id_user_id_fk", + "tableFrom": "user_tts_chars", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 3dc4ee8..406dc85 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1780625663601, "tag": "0009_drop_pdf_parse", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1780785284041, + "tag": "0010_user-data-cleanup-cascades", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 26e1a0f..e0130bc 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; -import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; export async function DELETE() { @@ -18,21 +18,12 @@ export async function DELETE() { } try { - // Best-effort cleanup for test namespaced storage using request context. - // The Better Auth beforeDelete hook still runs and handles non-namespaced data. + // Clean test-namespaced storage using request context. The Better Auth + // beforeDelete hook handles non-namespaced storage and blocks deletion if + // either cleanup cannot complete. const testNamespace = getOpenReaderTestNamespace(reqHeaders); if (testNamespace) { - try { - await deleteUserStorageData(session.user.id, testNamespace); - } catch (error) { - serverLogger.warn({ - event: 'account.delete.storage_cleanup_failed', - degraded: true, - step: 'namespaced_storage_cleanup', - userIdHash: hashForLog(session.user.id), - error: errorToLog(error), - }, 'Failed to clean up namespaced user storage before deletion'); - } + await deleteUserStorageData(session.user.id, testNamespace); } // Use Better Auth's built-in deleteUser to handle cascading cleanup diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 1929aeb..cd74281 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -9,6 +9,7 @@ import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace' import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache'; export const dynamic = 'force-dynamic'; @@ -80,9 +81,14 @@ export async function GET(req: NextRequest) { }); } catch (error) { if (isMissingBlobError(error)) { + await deleteDocumentTtsSegmentCache({ + userId: doc.userId, + documentId: id, + namespace: testNamespace, + }); await db .delete(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + .where(and(eq(documents.id, id), eq(documents.userId, doc.userId))); return NextResponse.json({ error: 'Not found' }, { status: 404 }); } throw error; diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index c67da8d..83e17d4 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -22,6 +22,7 @@ import { import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache'; export const dynamic = 'force-dynamic'; @@ -108,9 +109,14 @@ export async function GET(req: NextRequest) { ); } catch (error) { if (isMissingDocumentBlobError(error)) { + await deleteDocumentTtsSegmentCache({ + userId: doc.userId, + documentId: id, + namespace: testNamespace, + }); await db .delete(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + .where(and(eq(documents.id, id), eq(documents.userId, doc.userId))); return NextResponse.json({ error: 'Not found' }, { status: 404 }); } throw error; diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index fa34474..87c89a7 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -13,6 +13,7 @@ import { import { deleteDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import type { BaseDocument, DocumentType } from '@/types/documents'; export const dynamic = 'force-dynamic'; @@ -136,6 +137,25 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ success: true, deleted: 0 }); } + const ownedRows = (await db + .select({ id: documents.id, userId: documents.userId }) + .from(documents) + .where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds)))) as Array<{ + id: string; + userId: string; + }>; + + // TTS audio is user-scoped even when the underlying document blob is + // shared. Remove it before deleting ownership metadata so failures remain + // retryable and cannot create untraceable S3 objects. + for (const row of ownedRows) { + await deleteDocumentTtsSegmentCache({ + userId: row.userId, + documentId: row.id, + namespace: testNamespace, + }); + } + const deletedRows = (await db .delete(documents) .where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds))) diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index 66920bf..dc7af36 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -2,12 +2,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { PassThrough, Readable } from 'stream'; import { auth } from '@/lib/server/auth/auth'; import { db } from '@/db'; -import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema'; +import { + documents, + audiobooks, + audiobookChapters, + documentSettings, + ttsSegmentEntries, + ttsSegmentVariants, + userDocumentProgress, + userJobEvents, + userPreferences, + userTtsChars, +} from '@/db/schema'; +import * as authSchemaSqlite from '@/db/schema_auth_sqlite'; +import * as authSchemaPostgres from '@/db/schema_auth_postgres'; import { and, desc, eq, inArray } from 'drizzle-orm'; import archiver from 'archiver'; import { appendUserExportArchive } from '@/lib/server/user/data-export'; import { getDocumentBlobStream } from '@/lib/server/documents/blobstore'; import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore'; +import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore'; import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { nowTimestampMs } from '@/lib/shared/timestamps'; @@ -17,13 +31,6 @@ import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { - if (!isS3Configured()) { - return NextResponse.json( - { error: 'Export storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); - } - if (!auth) { return errorResponse(new Error('Auth not initialized'), { apiErrorMessage: 'Auth not initialized', @@ -41,11 +48,21 @@ export async function GET(req: NextRequest) { const userId = session.user.id; const testNamespace = getOpenReaderTestNamespace(req.headers); + const storageEnabled = isS3Configured(); + const requireStorage = () => { + if (!storageEnabled) { + throw new Error('Storage is not configured; file content could not be exported'); + } + }; const [ prefs, progress, ttsUsage, + jobEvents, + perDocumentSettings, + segmentEntries, + segmentVariants, userDocs, userAudiobooks, ] = await Promise.all([ @@ -60,6 +77,26 @@ export async function GET(req: NextRequest) { .from(userTtsChars) .where(eq(userTtsChars.userId, userId)) .orderBy(desc(userTtsChars.date)), + db + .select() + .from(userJobEvents) + .where(eq(userJobEvents.userId, userId)) + .orderBy(desc(userJobEvents.createdAt)), + db + .select() + .from(documentSettings) + .where(eq(documentSettings.userId, userId)) + .orderBy(desc(documentSettings.updatedAt)), + db + .select() + .from(ttsSegmentEntries) + .where(eq(ttsSegmentEntries.userId, userId)) + .orderBy(desc(ttsSegmentEntries.updatedAt)), + db + .select() + .from(ttsSegmentVariants) + .where(eq(ttsSegmentVariants.userId, userId)) + .orderBy(desc(ttsSegmentVariants.updatedAt)), db .select() .from(documents) @@ -72,6 +109,36 @@ export async function GET(req: NextRequest) { .orderBy(desc(audiobooks.createdAt)), ]); + const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite; + // Auth exports intentionally select metadata only. Credential and session + // secrets must never be written into the archive. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const database = db as any; + const [authSessions, linkedAccounts] = await Promise.all([ + database + .select({ + id: authSchema.session.id, + expiresAt: authSchema.session.expiresAt, + createdAt: authSchema.session.createdAt, + updatedAt: authSchema.session.updatedAt, + ipAddress: authSchema.session.ipAddress, + userAgent: authSchema.session.userAgent, + }) + .from(authSchema.session) + .where(eq(authSchema.session.userId, userId)), + database + .select({ + id: authSchema.account.id, + accountId: authSchema.account.accountId, + providerId: authSchema.account.providerId, + scope: authSchema.account.scope, + createdAt: authSchema.account.createdAt, + updatedAt: authSchema.account.updatedAt, + }) + .from(authSchema.account) + .where(eq(authSchema.account.userId, userId)), + ]); + const archive = archiver('zip', { zlib: { level: 0 }, forceZip64: true, @@ -114,7 +181,6 @@ export async function GET(req: NextRequest) { const exportedAtMs = nowTimestampMs(); const profileData = { user: session.user, - session: session.session, exportedAtMs, }; @@ -126,13 +192,31 @@ export async function GET(req: NextRequest) { preferences: prefs[0] ?? null, readingHistory: progress, ttsUsage, + jobEvents, + documentSettings: perDocumentSettings, + authSessions, + linkedAccounts, documents: userDocs, audiobooks: userAudiobooks, audiobookChapters: allChapters, - getDocumentBlobStream: async (documentId: string) => getDocumentBlobStream(documentId, testNamespace), - listAudiobookObjects: async (bookId: string, ownerId: string) => listAudiobookObjects(bookId, ownerId, testNamespace), - getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) => - getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace), + ttsSegmentEntries: segmentEntries, + ttsSegmentVariants: segmentVariants, + getDocumentBlobStream: async (documentId: string) => { + requireStorage(); + return getDocumentBlobStream(documentId, testNamespace); + }, + listAudiobookObjects: async (bookId: string, ownerId: string) => { + requireStorage(); + return listAudiobookObjects(bookId, ownerId, testNamespace); + }, + getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) => { + requireStorage(); + return getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace); + }, + getTtsSegmentAudioStream: async (audioKey: string) => { + requireStorage(); + return (await getTtsSegmentAudioObjectStream(audioKey)).stream; + }, }); if (!req.signal.aborted) { diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 63acd4f..e55436d 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -54,7 +54,7 @@ export const audiobookChapters = pgTable('audiobook_chapters', { // defined here. Only application-specific tables belong in this file. export const userTtsChars = pgTable("user_tts_chars", { - userId: text('user_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), date: date('date').notNull(), charCount: bigint('char_count', { mode: 'number' }).default(0), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), @@ -71,7 +71,7 @@ export const userTtsChars = pgTable("user_tts_chars", { // worker bounds each op by a hard cap, "ops created in the last hard-cap // window" is an upper bound on in-flight ops. Old rows are pruned opportunistically. export const userJobEvents = pgTable('user_job_events', { - userId: text('user_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), action: text('action').notNull(), opId: text('op_id').notNull(), createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 475ebc7..441e884 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -54,7 +54,7 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', { // defined here. Only application-specific tables belong in this file. export const userTtsChars = sqliteTable("user_tts_chars", { - userId: text('user_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard charCount: integer('char_count').default(0), createdAt: integer('created_at').default(SQLITE_NOW_MS), @@ -71,7 +71,7 @@ export const userTtsChars = sqliteTable("user_tts_chars", { // worker bounds each op by a hard cap, "ops created in the last hard-cap // window" is an upper bound on in-flight ops. Old rows are pruned opportunistically. export const userJobEvents = sqliteTable('user_job_events', { - userId: text('user_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), action: text('action').notNull(), opId: text('op_id').notNull(), createdAt: integer('created_at').notNull().default(SQLITE_NOW_MS), diff --git a/src/lib/server/audiobooks/blobstore.ts b/src/lib/server/audiobooks/blobstore.ts index 4d5549e..a5138d5 100644 --- a/src/lib/server/audiobooks/blobstore.ts +++ b/src/lib/server/audiobooks/blobstore.ts @@ -255,7 +255,11 @@ export async function deleteAudiobookPrefix(prefix: string): Promise { }, }), ); - deleted += deleteRes.Deleted?.length ?? 0; + const errors = deleteRes.Errors ?? []; + if (errors.length > 0) { + throw new Error(`Failed deleting ${errors.length} audiobook storage objects`); + } + deleted += keys.length; } continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index c3dd2cf..53135a3 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -96,8 +96,9 @@ const createAuth = () => betterAuth({ context: { userIdHash: hashForLog(user.id) }, error, }); - // Don't throw – allow the user deletion to proceed even if S3 cleanup fails. - // Orphaned blobs are preferable to a blocked account deletion. + // Without a durable cleanup queue, proceeding would permanently + // orphan user-scoped storage and non-cascading database rows. + throw error; } }, }, diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index 732ca0b..3f94bf0 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -507,10 +507,10 @@ export async function deleteDocumentBlob(id: string, namespace: string | null): const parsedPrefix = `${cfg.prefix}/documents_v1/parsed_v2/${nsSegment}${id}/`; await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); - await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })).catch(() => undefined); - await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); - await deleteDocumentPrefix(parsedPrefix).catch(() => undefined); - await deleteDocumentPrefix(`${key}/`).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })); + await deleteDocumentPrefix(parsedPrefix); + await deleteDocumentPrefix(`${key}/`); } export async function deleteTempDocumentUpload(token: string, userId: string, namespace: string | null): Promise { @@ -564,7 +564,11 @@ export async function deleteDocumentPrefix(prefix: string): Promise { }, }), ); - deleted += deleteRes.Deleted?.length ?? 0; + const errors = deleteRes.Errors ?? []; + if (errors.length > 0) { + throw new Error(`Failed deleting ${errors.length} document storage objects`); + } + deleted += keys.length; } continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; diff --git a/src/lib/server/documents/register-upload.ts b/src/lib/server/documents/register-upload.ts index 9e1d84e..a29feb8 100644 --- a/src/lib/server/documents/register-upload.ts +++ b/src/lib/server/documents/register-upload.ts @@ -1,9 +1,11 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; +import { and, eq } from 'drizzle-orm'; import { enqueueDocumentPreview, } from '@/lib/server/documents/previews'; import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import type { BaseDocument, DocumentType } from '@/types/documents'; type RegisterUploadedDocumentInput = { @@ -17,6 +19,23 @@ type RegisterUploadedDocumentInput = { }; export async function registerUploadedDocument(input: RegisterUploadedDocumentInput): Promise { + const [existing] = await db + .select({ lastModified: documents.lastModified }) + .from(documents) + .where(and( + eq(documents.id, input.documentId), + eq(documents.userId, input.userId), + )) + .limit(1); + + if (existing && Number(existing.lastModified) !== input.lastModified) { + await deleteDocumentTtsSegmentCache({ + userId: input.userId, + documentId: input.documentId, + namespace: input.namespace, + }); + } + await db .insert(documents) .values({ diff --git a/src/lib/server/tts/segments-blobstore.ts b/src/lib/server/tts/segments-blobstore.ts index de2d56d..94e673b 100644 --- a/src/lib/server/tts/segments-blobstore.ts +++ b/src/lib/server/tts/segments-blobstore.ts @@ -244,7 +244,12 @@ export async function deleteTtsSegmentPrefix(prefix: string): Promise { }, }), ); - deleted += deleteRes.Deleted?.length ?? 0; + const errors = deleteRes.Errors ?? []; + if (errors.length > 0) { + throw new Error(`Failed deleting ${errors.length} TTS segment audio objects`); + } + // Quiet=true commonly omits Deleted entries on successful requests. + deleted += keys.length; } continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; diff --git a/src/lib/server/tts/segments-cache.ts b/src/lib/server/tts/segments-cache.ts index bad4b1f..b3d5803 100644 --- a/src/lib/server/tts/segments-cache.ts +++ b/src/lib/server/tts/segments-cache.ts @@ -1,7 +1,12 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; +import { + deleteTtsSegmentAudioObjects, + deleteTtsSegmentPrefix, +} from '@/lib/server/tts/segments-blobstore'; +import { buildTtsSegmentDocumentPrefix } from '@/lib/server/tts/segments'; +import { getS3Config } from '@/lib/server/storage/s3'; import type { ReaderType } from '@/types/user-state'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded } from '@/lib/server/errors/logging'; @@ -50,8 +55,6 @@ export async function clearTtsSegmentCache( ) .where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>; - await db.delete(ttsSegmentEntries).where(and(...conditions)); - const audioKeys = rows .map((row) => row.audioKey) .filter((key): key is string => Boolean(key)); @@ -80,10 +83,38 @@ export async function clearTtsSegmentCache( } } + // Keep metadata when storage cleanup is incomplete so a later retry still + // knows which objects must be removed. + if (!warning) { + await db.delete(ttsSegmentEntries).where(and(...conditions)); + } + return { - deletedSegments: rows.length, + deletedSegments: warning ? 0 : rows.length, requestedAudioObjects: uniqueAudioKeys.length, deletedAudioObjects, ...(warning ? { warning } : {}), }; } + +export async function deleteDocumentTtsSegmentCache(input: { + userId: string; + documentId: string; + namespace: string | null; +}): Promise { + const storagePrefix = getS3Config().prefix; + for (const storageVersion of ['v1', 'v2'] as const) { + await deleteTtsSegmentPrefix(buildTtsSegmentDocumentPrefix({ + storagePrefix, + namespace: input.namespace, + userId: input.userId, + documentId: input.documentId, + storageVersion, + })); + } + + await db.delete(ttsSegmentEntries).where(and( + eq(ttsSegmentEntries.userId, input.userId), + eq(ttsSegmentEntries.documentId, input.documentId), + )); +} diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index e3618ae..08a10a6 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -226,6 +226,17 @@ export function buildTtsSegmentAudioKey(input: { return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`; } +export function buildTtsSegmentDocumentPrefix(input: { + storagePrefix: string; + namespace: string | null; + userId: string; + documentId: string; + storageVersion?: 'v1' | 'v2'; +}): string { + const nsSegment = input.namespace ? `ns/${input.namespace}/` : ''; + return `${input.storagePrefix}/tts_segments_${input.storageVersion ?? 'v2'}/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/`; +} + export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise { let workDir: string | null = null; try { diff --git a/src/lib/server/user/data-cleanup.ts b/src/lib/server/user/data-cleanup.ts index 43f18b2..0a13601 100644 --- a/src/lib/server/user/data-cleanup.ts +++ b/src/lib/server/user/data-cleanup.ts @@ -5,10 +5,16 @@ */ import { db } from '@/db'; -import { documents, audiobooks } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { documents, audiobooks, userJobEvents, userTtsChars } from '@/db/schema'; +import * as authSchemaSqlite from '@/db/schema_auth_sqlite'; +import * as authSchemaPostgres from '@/db/schema_auth_postgres'; +import { and, eq, ne } from 'drizzle-orm'; import { getS3Config, isS3Configured } from '@/lib/server/storage/s3'; -import { deleteDocumentBlob } from '@/lib/server/documents/blobstore'; +import { + deleteDocumentBlob, + deleteDocumentPrefix, + tempDocumentUploadPrefix, +} from '@/lib/server/documents/blobstore'; import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-blobstore'; import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews'; import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; @@ -34,9 +40,11 @@ export async function deleteUserStorageData( namespace: string | null, ): Promise { const s3Enabled = isS3Configured(); + const failures: unknown[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const database = db as any; + const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite; // --- Documents & previews --- const userDocs: DocumentRow[] = await database @@ -46,11 +54,22 @@ export async function deleteUserStorageData( let docsDeleted = 0; for (const doc of userDocs) { - if (s3Enabled) { + const otherOwners = await database + .select({ id: documents.id }) + .from(documents) + .where(and( + eq(documents.id, doc.id), + ne(documents.userId, userId), + )) + .limit(1); + const isLastOwner = otherOwners.length === 0; + + if (s3Enabled && isLastOwner) { try { await deleteDocumentBlob(doc.id, namespace); docsDeleted++; } catch (error) { + failures.push(error); logDegraded(serverLogger, { event: 'user.data_cleanup.document_blob_delete.failed', msg: 'Failed to delete document blob', @@ -66,6 +85,7 @@ export async function deleteUserStorageData( try { await deleteDocumentPreviewArtifacts(doc.id, namespace); } catch (error) { + failures.push(error); logDegraded(serverLogger, { event: 'user.data_cleanup.document_preview_delete.failed', msg: 'Failed to delete preview artifacts', @@ -79,20 +99,24 @@ export async function deleteUserStorageData( } } - // Always clean up DB rows — documentPreviews has no FK cascade on user. - try { - await deleteDocumentPreviewRows(doc.id, namespace); - } catch (error) { - logDegraded(serverLogger, { - event: 'user.data_cleanup.document_preview_rows_delete.failed', - msg: 'Failed to delete preview rows', - step: 'delete_document_preview_rows', - context: { - documentId: doc.id, - userIdHash: hashForLog(userId), - }, - error, - }); + // Preview rows/artifacts are shared by document id, so only the final + // owner may remove them. + if (isLastOwner) { + try { + await deleteDocumentPreviewRows(doc.id, namespace); + } catch (error) { + failures.push(error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.document_preview_rows_delete.failed', + msg: 'Failed to delete preview rows', + step: 'delete_document_preview_rows', + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); + } } } @@ -111,6 +135,7 @@ export async function deleteUserStorageData( await deleteAudiobookPrefix(prefix); booksDeleted++; } catch (error) { + failures.push(error); logDegraded(serverLogger, { event: 'user.data_cleanup.audiobook_blobs_delete.failed', msg: 'Failed to delete audiobook blobs', @@ -127,6 +152,19 @@ export async function deleteUserStorageData( // --- TTS segments --- let segmentsDeleted = 0; if (s3Enabled) { + try { + await deleteDocumentPrefix(tempDocumentUploadPrefix(userId, namespace)); + } catch (error) { + failures.push(error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.temp_document_uploads_delete.failed', + msg: 'Failed to delete temporary document uploads', + step: 'delete_temp_document_upload_prefix', + context: { userIdHash: hashForLog(userId) }, + error, + }); + } + try { const cfg = getS3Config(); const nsSegment = namespace ? `ns/${namespace}/` : ''; @@ -135,6 +173,7 @@ export async function deleteUserStorageData( segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); } catch (error) { + failures.push(error); logDegraded(serverLogger, { event: 'user.data_cleanup.tts_segments_delete.failed', msg: 'Failed to delete TTS segment blobs', @@ -145,6 +184,34 @@ export async function deleteUserStorageData( } } + if (failures.length > 0) { + throw new AggregateError(failures, `User storage cleanup failed in ${failures.length} operation(s)`); + } + + // Namespaced cleanup is an additional storage pass made by the account + // route. Database rows are global and must only be removed during the + // canonical non-namespaced beforeDelete pass. + if (namespace === null) { + // Delete explicitly for compatibility with pre-cascade installations and + // to remove auth verification tokens, which cannot carry a user FK. + for (const { table, userColumn, step } of [ + { table: userTtsChars, userColumn: userTtsChars.userId, step: 'delete_user_tts_usage_rows' }, + { table: userJobEvents, userColumn: userJobEvents.userId, step: 'delete_user_job_event_rows' }, + { table: authSchema.verification, userColumn: authSchema.verification.value, step: 'delete_user_verification_rows' }, + ]) { + await database.delete(table).where(eq(userColumn, userId)).catch((error: unknown) => { + failures.push(error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.db_rows_delete.failed', + msg: 'Failed to delete non-cascading user database rows', + step, + context: { userIdHash: hashForLog(userId) }, + error, + }); + }); + } + } + if (docsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) { serverLogger.info({ event: 'user.data_cleanup.completed', @@ -156,4 +223,8 @@ export async function deleteUserStorageData( segmentsDeleted, }, 'Completed user storage cleanup'); } + + if (failures.length > 0) { + throw new AggregateError(failures, `User database cleanup failed in ${failures.length} operation(s)`); + } } diff --git a/src/lib/server/user/data-export.ts b/src/lib/server/user/data-export.ts index f81de15..68b306b 100644 --- a/src/lib/server/user/data-export.ts +++ b/src/lib/server/user/data-export.ts @@ -11,7 +11,7 @@ export type ExportBlobBody = | ArrayBufferView | { transformToByteArray: () => Promise }; -type ExportIssueScope = 'document' | 'audiobook' | 'audiobook_list'; +type ExportIssueScope = 'document' | 'audiobook' | 'audiobook_list' | 'tts_segment'; type ExportIssue = { scope: ExportIssueScope; @@ -41,6 +41,20 @@ type ExportAudiobookObject = { [key: string]: unknown; }; +type ExportTtsSegmentEntry = { + segmentEntryId: string; + documentId: string; + [key: string]: unknown; +}; + +type ExportTtsSegmentVariant = { + segmentId: string; + segmentEntryId: string; + audioKey?: string | null; + audioFormat?: string | null; + [key: string]: unknown; +}; + export type AppendUserExportArchiveInput = { archive: Archiver; userId: string; @@ -49,12 +63,19 @@ export type AppendUserExportArchiveInput = { preferences: unknown | null; readingHistory: unknown[]; ttsUsage: unknown[]; + jobEvents: unknown[]; + documentSettings: unknown[]; + authSessions: unknown[]; + linkedAccounts: unknown[]; documents: ExportDocument[]; audiobooks: ExportAudiobook[]; audiobookChapters: ExportAudiobookChapter[]; + ttsSegmentEntries: ExportTtsSegmentEntry[]; + ttsSegmentVariants: ExportTtsSegmentVariant[]; getDocumentBlobStream: (documentId: string) => Promise; listAudiobookObjects: (bookId: string, userId: string) => Promise; getAudiobookObjectStream: (bookId: string, userId: string, fileName: string) => Promise; + getTtsSegmentAudioStream: (audioKey: string) => Promise; }; function isNodeReadableStream(value: unknown): value is Readable { @@ -124,17 +145,25 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu preferences, readingHistory, ttsUsage, + jobEvents, + documentSettings, + authSessions, + linkedAccounts, documents, audiobooks, audiobookChapters, + ttsSegmentEntries, + ttsSegmentVariants, getDocumentBlobStream, listAudiobookObjects, getAudiobookObjectStream, + getTtsSegmentAudioStream, } = input; const issues: ExportIssue[] = []; let documentFilesExported = 0; let audiobookFilesExported = 0; + let ttsSegmentFilesExported = 0; appendJson(archive, 'profile.json', profileData); if (preferences) { @@ -142,7 +171,13 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu } appendJson(archive, 'reading_history.json', readingHistory); appendJson(archive, 'tts_usage.json', ttsUsage); + appendJson(archive, 'job_events.json', jobEvents); + appendJson(archive, 'document_settings.json', documentSettings); + appendJson(archive, 'auth_sessions.json', authSessions); + appendJson(archive, 'linked_accounts.json', linkedAccounts); appendJson(archive, 'library_documents.json', documents); + appendJson(archive, 'tts_segment_entries.json', ttsSegmentEntries); + appendJson(archive, 'tts_segment_variants.json', ttsSegmentVariants); const chaptersByBookId = new Map(); for (const chapter of audiobookChapters) { @@ -215,8 +250,40 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu } } + const documentIdByEntryId = new Map( + ttsSegmentEntries.map((entry) => [entry.segmentEntryId, entry.documentId]), + ); + const exportedAudioKeys = new Set(); + for (const variant of ttsSegmentVariants) { + const audioKey = typeof variant.audioKey === 'string' ? variant.audioKey : ''; + if (!audioKey || exportedAudioKeys.has(audioKey)) continue; + exportedAudioKeys.add(audioKey); + + const documentId = toSafePathSegment( + documentIdByEntryId.get(variant.segmentEntryId) ?? 'unknown-document', + 'unknown-document', + ); + const segmentId = toSafePathSegment(variant.segmentId, 'segment'); + const format = toSafePathSegment(variant.audioFormat || 'mp3', 'mp3'); + const entryName = `files/tts_segments/${documentId}/${segmentId}.${format}`; + + try { + const body = await getTtsSegmentAudioStream(audioKey); + const stream = await bodyToNodeReadable(body); + archive.append(stream, { name: entryName }); + ttsSegmentFilesExported += 1; + } catch (error) { + issues.push({ + scope: 'tts_segment', + id: variant.segmentId, + fileName: entryName, + message: normalizeErrorMessage(error), + }); + } + } + const manifest = { - formatVersion: 2, + formatVersion: 3, exportedAtMs, userId, scope: 'owned', @@ -224,14 +291,26 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu documentsMetadata: documents.length, audiobooksMetadata: audiobooks.length, audiobookChaptersMetadata: audiobookChapters.length, + documentSettingsMetadata: documentSettings.length, + ttsSegmentEntriesMetadata: ttsSegmentEntries.length, + ttsSegmentVariantsMetadata: ttsSegmentVariants.length, + authSessionsMetadata: authSessions.length, + linkedAccountsMetadata: linkedAccounts.length, + jobEventsMetadata: jobEvents.length, documentFiles: documentFilesExported, audiobookFiles: audiobookFilesExported, + ttsSegmentFiles: ttsSegmentFilesExported, issues: issues.length, }, includes: { metadata: true, documentFiles: true, audiobookFiles: true, + ttsSegmentFiles: true, + credentialSecrets: false, + temporaryUploads: false, + derivedDocumentPreviews: false, + derivedParsedDocuments: false, filesystemSources: false, }, }; diff --git a/tests/unit/data-export.vitest.spec.ts b/tests/unit/data-export.vitest.spec.ts new file mode 100644 index 0000000..48fab66 --- /dev/null +++ b/tests/unit/data-export.vitest.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; +import type { Archiver } from 'archiver'; +import { appendUserExportArchive } from '../../src/lib/server/user/data-export'; + +describe('user data export archive', () => { + test('includes user metadata and cached TTS audio without credential secrets', async () => { + const entries = new Map(); + const archive = { + append(value: unknown, options: { name: string }) { + entries.set(options.name, value); + return archive; + }, + } as unknown as Archiver; + + await appendUserExportArchive({ + archive, + userId: 'user-1', + exportedAtMs: 123, + profileData: { user: { id: 'user-1', email: 'person@example.com' }, exportedAtMs: 123 }, + preferences: { dataJson: '{}' }, + readingHistory: [{ documentId: 'doc-1' }], + ttsUsage: [{ charCount: 10 }], + jobEvents: [{ action: 'pdf-layout' }], + documentSettings: [{ documentId: 'doc-1', dataJson: '{}' }], + authSessions: [{ id: 'session-1', ipAddress: null }], + linkedAccounts: [{ id: 'account-1', providerId: 'credential' }], + documents: [], + audiobooks: [], + audiobookChapters: [], + ttsSegmentEntries: [{ segmentEntryId: 'entry-1', documentId: 'doc-1' }], + ttsSegmentVariants: [{ + segmentId: 'segment-1', + segmentEntryId: 'entry-1', + audioKey: 'private/audio/key', + audioFormat: 'mp3', + }], + getDocumentBlobStream: async () => new Uint8Array(), + listAudiobookObjects: async () => [], + getAudiobookObjectStream: async () => new Uint8Array(), + getTtsSegmentAudioStream: async () => new Uint8Array([1, 2, 3]), + }); + + expect(entries.has('document_settings.json')).toBe(true); + expect(entries.has('auth_sessions.json')).toBe(true); + expect(entries.has('linked_accounts.json')).toBe(true); + expect(entries.has('tts_segment_entries.json')).toBe(true); + expect(entries.has('tts_segment_variants.json')).toBe(true); + expect(entries.has('files/tts_segments/doc-1/segment-1.mp3')).toBe(true); + + const manifest = JSON.parse(String(entries.get('export_manifest.json'))); + expect(manifest).toMatchObject({ + formatVersion: 3, + counts: { + ttsSegmentEntriesMetadata: 1, + ttsSegmentVariantsMetadata: 1, + ttsSegmentFiles: 1, + }, + includes: { + credentialSecrets: false, + }, + }); + }); +}); diff --git a/tests/unit/storage-prefix-cleanup.vitest.spec.ts b/tests/unit/storage-prefix-cleanup.vitest.spec.ts new file mode 100644 index 0000000..0bc77fc --- /dev/null +++ b/tests/unit/storage-prefix-cleanup.vitest.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + send: vi.fn(), +})); + +vi.mock('@/lib/server/storage/s3', () => ({ + getS3Config: () => ({ bucket: 'test-bucket', prefix: 'openreader-test' }), + getS3Client: () => ({ send: mocks.send }), + getS3ProxyClient: () => ({ send: mocks.send }), +})); + +import { deleteAudiobookPrefix } from '../../src/lib/server/audiobooks/blobstore'; +import { deleteDocumentPrefix } from '../../src/lib/server/documents/blobstore'; + +describe('storage prefix cleanup', () => { + beforeEach(() => { + mocks.send.mockReset(); + }); + + test.each([ + ['document', deleteDocumentPrefix], + ['audiobook', deleteAudiobookPrefix], + ])('%s cleanup counts successful quiet deletes', async (_name, removePrefix) => { + mocks.send + .mockResolvedValueOnce({ + Contents: [{ Key: 'prefix/a' }, { Key: 'prefix/b' }], + IsTruncated: false, + }) + .mockResolvedValueOnce({}); + + await expect(removePrefix('prefix/')).resolves.toBe(2); + }); + + test.each([ + ['document', deleteDocumentPrefix], + ['audiobook', deleteAudiobookPrefix], + ])('%s cleanup fails on per-object storage errors', async (_name, removePrefix) => { + mocks.send + .mockResolvedValueOnce({ + Contents: [{ Key: 'prefix/a' }], + IsTruncated: false, + }) + .mockResolvedValueOnce({ + Errors: [{ Key: 'prefix/a', Code: 'AccessDenied' }], + }); + + await expect(removePrefix('prefix/')).rejects.toThrow('Failed deleting 1'); + }); +}); diff --git a/tests/unit/tts-segments-blobstore.vitest.spec.ts b/tests/unit/tts-segments-blobstore.vitest.spec.ts new file mode 100644 index 0000000..e1cb8d9 --- /dev/null +++ b/tests/unit/tts-segments-blobstore.vitest.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + send: vi.fn(), +})); + +vi.mock('@/lib/server/storage/s3', () => ({ + getS3Config: () => ({ bucket: 'test-bucket' }), + getS3Client: () => ({ send: mocks.send }), + getS3ProxyClient: () => ({ send: mocks.send }), +})); + +import { deleteTtsSegmentPrefix } from '../../src/lib/server/tts/segments-blobstore'; + +describe('TTS segment blob cleanup', () => { + beforeEach(() => { + mocks.send.mockReset(); + }); + + test('counts successful quiet deletes by requested object count', async () => { + mocks.send + .mockResolvedValueOnce({ + Contents: [{ Key: 'prefix/a.mp3' }, { Key: 'prefix/b.mp3' }], + IsTruncated: false, + }) + .mockResolvedValueOnce({}); + + await expect(deleteTtsSegmentPrefix('prefix/')).resolves.toBe(2); + }); + + test('fails cleanup when storage reports per-object deletion errors', async () => { + mocks.send + .mockResolvedValueOnce({ + Contents: [{ Key: 'prefix/a.mp3' }], + IsTruncated: false, + }) + .mockResolvedValueOnce({ + Errors: [{ Key: 'prefix/a.mp3', Code: 'AccessDenied' }], + }); + + await expect(deleteTtsSegmentPrefix('prefix/')).rejects.toThrow( + 'Failed deleting 1 TTS segment audio objects', + ); + }); +}); diff --git a/tests/unit/tts-segments.vitest.spec.ts b/tests/unit/tts-segments.vitest.spec.ts index a448afe..a257c23 100644 --- a/tests/unit/tts-segments.vitest.spec.ts +++ b/tests/unit/tts-segments.vitest.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import { + buildTtsSegmentDocumentPrefix, buildProportionalAlignment, buildTtsSegmentEntryId, buildTtsSegmentId, @@ -12,6 +13,15 @@ import { } from '../../src/lib/server/tts/segments'; describe('tts segment helpers', () => { + test('builds a user/document-scoped audio prefix across every version and variant', () => { + expect(buildTtsSegmentDocumentPrefix({ + storagePrefix: 'openreader', + namespace: 'test namespace', + userId: 'user/name', + documentId: 'doc-id', + })).toBe('openreader/tts_segments_v2/ns/test namespace/users/user%2Fname/docs/doc-id/'); + }); + test('builds stable settings hash', () => { const a = buildTtsSegmentSettingsHash({ providerRef: 'openai', diff --git a/tests/unit/user-data-cleanup.vitest.spec.ts b/tests/unit/user-data-cleanup.vitest.spec.ts new file mode 100644 index 0000000..1d613f9 --- /dev/null +++ b/tests/unit/user-data-cleanup.vitest.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + selectResults: [] as unknown[][], + deleteWhere: vi.fn(async () => undefined), + deleteDocumentBlob: vi.fn(async () => undefined), + deleteDocumentPrefix: vi.fn(async () => 0), + deleteDocumentPreviewArtifacts: vi.fn(async () => 0), + deleteDocumentPreviewRows: vi.fn(async () => undefined), + deleteAudiobookPrefix: vi.fn(async () => 0), + deleteTtsSegmentPrefix: vi.fn(async () => 0), +})); + +function resultBuilder(result: unknown[]) { + return { + limit: async () => result, + then: (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject), + }; +} + +vi.mock('@/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => resultBuilder(mocks.selectResults.shift() ?? [])), + })), + })), + delete: vi.fn(() => ({ + where: mocks.deleteWhere, + })), + }, +})); + +vi.mock('@/lib/server/storage/s3', () => ({ + isS3Configured: () => true, + getS3Config: () => ({ prefix: 'openreader-test' }), +})); + +vi.mock('@/lib/server/documents/blobstore', () => ({ + deleteDocumentBlob: mocks.deleteDocumentBlob, + deleteDocumentPrefix: mocks.deleteDocumentPrefix, + tempDocumentUploadPrefix: () => 'temp/user/', +})); + +vi.mock('@/lib/server/documents/previews-blobstore', () => ({ + deleteDocumentPreviewArtifacts: mocks.deleteDocumentPreviewArtifacts, +})); + +vi.mock('@/lib/server/documents/previews', () => ({ + deleteDocumentPreviewRows: mocks.deleteDocumentPreviewRows, +})); + +vi.mock('@/lib/server/audiobooks/blobstore', () => ({ + audiobookPrefix: () => 'audiobooks/user/', + deleteAudiobookPrefix: mocks.deleteAudiobookPrefix, +})); + +vi.mock('@/lib/server/tts/segments-blobstore', () => ({ + deleteTtsSegmentPrefix: mocks.deleteTtsSegmentPrefix, +})); + +vi.mock('@/lib/server/logger', () => ({ + hashForLog: () => 'hash', + serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/lib/server/errors/logging', () => ({ + logDegraded: vi.fn(), +})); + +import { deleteUserStorageData } from '../../src/lib/server/user/data-cleanup'; + +describe('user data cleanup', () => { + beforeEach(() => { + mocks.selectResults = []; + for (const mock of Object.values(mocks)) { + if (typeof mock === 'function' && 'mockReset' in mock) { + mock.mockReset(); + } + } + mocks.deleteWhere.mockResolvedValue(undefined); + mocks.deleteDocumentBlob.mockResolvedValue(undefined); + mocks.deleteDocumentPrefix.mockResolvedValue(0); + mocks.deleteDocumentPreviewArtifacts.mockResolvedValue(0); + mocks.deleteDocumentPreviewRows.mockResolvedValue(undefined); + mocks.deleteAudiobookPrefix.mockResolvedValue(0); + mocks.deleteTtsSegmentPrefix.mockResolvedValue(0); + }); + + test('keeps shared document blobs and previews', async () => { + mocks.selectResults = [ + [{ id: 'shared-doc' }], + [{ id: 'shared-doc' }], + [], + ]; + + await deleteUserStorageData('user-1', null); + + expect(mocks.deleteDocumentBlob).not.toHaveBeenCalled(); + expect(mocks.deleteDocumentPreviewArtifacts).not.toHaveBeenCalled(); + expect(mocks.deleteDocumentPreviewRows).not.toHaveBeenCalled(); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(3); + }); + + test('blocks database cleanup when storage cleanup fails', async () => { + mocks.selectResults = [[], []]; + mocks.deleteDocumentPrefix.mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(deleteUserStorageData('user-1', null)).rejects.toThrow( + 'User storage cleanup failed', + ); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }); +});