feat: harden database schema

This commit is contained in:
neon
2026-04-05 18:07:11 -06:00
parent 9fd4e162aa
commit 47e5acd9e6
47 changed files with 2519 additions and 677 deletions
+239 -116
View File
@@ -1,3 +1,5 @@
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
CREATE TYPE "public"."library_access_level" AS ENUM('viewer', 'editor', 'owner');--> statement-breakpoint
CREATE TYPE "public"."user_avatar_source" AS ENUM('none', 'external', 'uploaded');--> statement-breakpoint
CREATE TYPE "public"."opds_sort_order" AS ENUM('recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc');--> statement-breakpoint
@@ -11,14 +13,14 @@ CREATE TABLE "audit_log" (
"description" text NOT NULL,
"ip" varchar(45),
"meta" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "app_settings" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar(100) NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "app_settings_key_unique" UNIQUE("key")
);
--> statement-breakpoint
@@ -26,20 +28,22 @@ CREATE TABLE "password_reset_tokens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"token_hash" varchar(255) NOT NULL,
"expires_at" timestamp NOT NULL,
"used_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash")
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash"),
CONSTRAINT "password_reset_tokens_expires_after_created_chk" CHECK ("password_reset_tokens"."expires_at" > "password_reset_tokens"."created_at")
);
--> statement-breakpoint
CREATE TABLE "refresh_tokens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"token_hash" varchar(255) NOT NULL,
"expires_at" timestamp NOT NULL,
"revoked_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "refresh_tokens_token_hash_unique" UNIQUE("token_hash")
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "refresh_tokens_token_hash_unique" UNIQUE("token_hash"),
CONSTRAINT "refresh_tokens_expires_after_created_chk" CHECK ("refresh_tokens"."expires_at" > "refresh_tokens"."created_at")
);
--> statement-breakpoint
CREATE TABLE "user_library_access" (
@@ -72,10 +76,13 @@ CREATE TABLE "users" (
"avatar_source" "user_avatar_source" DEFAULT 'none' NOT NULL,
"avatar_version" integer DEFAULT 0 NOT NULL,
"provisioning_method" varchar(20) DEFAULT 'local' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username"),
CONSTRAINT "users_email_unique" UNIQUE("email")
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_provisioning_method_chk" CHECK ("users"."provisioning_method" in ('local', 'manual', 'oidc')),
CONSTRAINT "users_token_version_nonnegative_chk" CHECK ("users"."token_version" >= 0),
CONSTRAINT "users_avatar_version_nonnegative_chk" CHECK ("users"."avatar_version" >= 0)
);
--> statement-breakpoint
CREATE TABLE "comic_metadata" (
@@ -92,8 +99,8 @@ CREATE TABLE "comic_metadata" (
"teams" text[],
"locations" text[],
"story_arcs" text[],
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "comic_metadata_book_id_unique" UNIQUE("book_id")
);
--> statement-breakpoint
@@ -108,8 +115,8 @@ CREATE TABLE "migration_plan_artifacts" (
"source_data" jsonb,
"summary" jsonb NOT NULL,
"created_by_user_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "migration_profiles" (
@@ -121,8 +128,9 @@ CREATE TABLE "migration_profiles" (
"path_mappings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"scope" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_by_user_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "migration_profiles_version_positive_chk" CHECK ("migration_profiles"."version" >= 1)
);
--> statement-breakpoint
CREATE TABLE "migration_run_metrics" (
@@ -135,8 +143,13 @@ CREATE TABLE "migration_run_metrics" (
"skipped" integer DEFAULT 0 NOT NULL,
"unresolved" integer DEFAULT 0 NOT NULL,
"failed" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "migration_run_metrics_processed_nonnegative_chk" CHECK ("migration_run_metrics"."processed" >= 0),
CONSTRAINT "migration_run_metrics_imported_nonnegative_chk" CHECK ("migration_run_metrics"."imported" >= 0),
CONSTRAINT "migration_run_metrics_skipped_nonnegative_chk" CHECK ("migration_run_metrics"."skipped" >= 0),
CONSTRAINT "migration_run_metrics_unresolved_nonnegative_chk" CHECK ("migration_run_metrics"."unresolved" >= 0),
CONSTRAINT "migration_run_metrics_failed_nonnegative_chk" CHECK ("migration_run_metrics"."failed" >= 0)
);
--> statement-breakpoint
CREATE TABLE "migration_runs" (
@@ -148,12 +161,13 @@ CREATE TABLE "migration_runs" (
"state" varchar(32) DEFAULT 'draft' NOT NULL,
"current_stage" varchar(64),
"triggered_by_user_id" integer,
"started_at" timestamp,
"ended_at" timestamp,
"started_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"error_message" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "migration_runs_state_chk" CHECK ("state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed'))
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "migration_runs_state_chk" CHECK ("state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')),
CONSTRAINT "migration_runs_started_before_ended_chk" CHECK ("migration_runs"."ended_at" is null or "migration_runs"."started_at" is null or "migration_runs"."ended_at" >= "migration_runs"."started_at")
);
--> statement-breakpoint
CREATE TABLE "migration_sources" (
@@ -162,10 +176,10 @@ CREATE TABLE "migration_sources" (
"name" varchar(255) NOT NULL,
"connection_config" jsonb NOT NULL,
"capabilities" jsonb,
"last_validated_at" timestamp,
"last_validated_at" timestamp with time zone,
"created_by_user_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "libraries" (
@@ -186,19 +200,25 @@ CREATE TABLE "libraries" (
"file_naming_pattern" varchar(500),
"metadata_fetch_preferences" jsonb,
"book_metadata_fetch_config" jsonb,
"book_metadata_fetch_last_run_at" timestamp,
"book_metadata_fetch_last_run_at" timestamp with time zone,
"book_metadata_fetch_last_queued_count" integer,
"scan_mode" varchar(20) DEFAULT 'auto' NOT NULL,
"poll_interval_seconds" integer DEFAULT 300,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "libraries_display_order_nonnegative_chk" CHECK ("libraries"."display_order" >= 0),
CONSTRAINT "libraries_organization_mode_chk" CHECK ("libraries"."organization_mode" in ('book_per_folder', 'file_per_book')),
CONSTRAINT "libraries_reading_threshold_range_chk" CHECK ("libraries"."reading_threshold" >= 0 and "libraries"."reading_threshold" <= 1),
CONSTRAINT "libraries_mark_finished_percent_range_chk" CHECK ("libraries"."mark_as_finished_percent_complete" >= 0 and "libraries"."mark_as_finished_percent_complete" <= 100),
CONSTRAINT "libraries_scan_mode_chk" CHECK ("libraries"."scan_mode" in ('auto', 'manual')),
CONSTRAINT "libraries_poll_interval_nonnegative_chk" CHECK ("libraries"."poll_interval_seconds" is null or "libraries"."poll_interval_seconds" >= 0)
);
--> statement-breakpoint
CREATE TABLE "library_folders" (
"id" serial PRIMARY KEY NOT NULL,
"library_id" integer NOT NULL,
"path" varchar(4096) NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "book_files" (
@@ -209,14 +229,17 @@ CREATE TABLE "book_files" (
"rel_path" varchar(4096),
"ino" bigint NOT NULL,
"size_bytes" bigint,
"mtime" timestamp,
"mtime" timestamp with time zone,
"hash" varchar(64),
"format" varchar(20),
"role" varchar(20) DEFAULT 'content' NOT NULL,
"sort_order" integer,
"duration_seconds" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_files_role_chk" CHECK ("book_files"."role" in ('content', 'cover', 'supplement')),
CONSTRAINT "book_files_size_bytes_nonnegative_chk" CHECK ("book_files"."size_bytes" is null or "book_files"."size_bytes" >= 0),
CONSTRAINT "book_files_duration_seconds_nonnegative_chk" CHECK ("book_files"."duration_seconds" is null or "book_files"."duration_seconds" >= 0)
);
--> statement-breakpoint
CREATE TABLE "books" (
@@ -226,14 +249,15 @@ CREATE TABLE "books" (
"primary_file_id" integer,
"folder_path" varchar(4096) NOT NULL,
"status" varchar(20) DEFAULT 'present' NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "books_status_chk" CHECK ("books"."status" in ('present', 'missing'))
);
--> statement-breakpoint
CREATE TABLE "collection_books" (
"collection_id" integer NOT NULL,
"book_id" integer NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "collection_books_collection_id_book_id_pk" PRIMARY KEY("collection_id","book_id")
);
--> statement-breakpoint
@@ -245,8 +269,8 @@ CREATE TABLE "collections" (
"description" text,
"sync_to_kobo" boolean DEFAULT false NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lenses" (
@@ -258,8 +282,8 @@ CREATE TABLE "lenses" (
"default_sort" jsonb DEFAULT '[]'::jsonb NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lenses_user_id_name_unique" UNIQUE("user_id","name")
);
--> statement-breakpoint
@@ -268,13 +292,16 @@ CREATE TABLE "author_enrichment_queue" (
"status" varchar(20) DEFAULT 'queued' NOT NULL,
"reason" varchar(50) DEFAULT 'unknown' NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp DEFAULT now() NOT NULL,
"last_attempt_at" timestamp,
"last_success_at" timestamp,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_attempt_at" timestamp with time zone,
"last_success_at" timestamp with time zone,
"last_error" text,
"last_http_status" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "author_enrichment_queue_status_chk" CHECK ("author_enrichment_queue"."status" in ('queued', 'processing', 'rate_limited', 'failed', 'done')),
CONSTRAINT "author_enrichment_queue_reason_chk" CHECK ("author_enrichment_queue"."reason" in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')),
CONSTRAINT "author_enrichment_queue_attempt_count_nonnegative_chk" CHECK ("author_enrichment_queue"."attempt_count" >= 0)
);
--> statement-breakpoint
CREATE TABLE "authors" (
@@ -283,7 +310,7 @@ CREATE TABLE "authors" (
"sort_name" varchar(500),
"description" text,
"has_photo" boolean DEFAULT false NOT NULL,
"last_enriched_at" timestamp,
"last_enriched_at" timestamp with time zone,
CONSTRAINT "authors_name_unique" UNIQUE("name")
);
--> statement-breakpoint
@@ -322,16 +349,21 @@ CREATE TABLE "book_metadata" (
"open_library_id" varchar(50),
"itunes_id" varchar(50),
"metadata_score" integer,
"last_metadata_fetch_at" timestamp,
"last_metadata_fetch_at" timestamp with time zone,
"embedding" vector(256),
"last_written_at" timestamp,
"last_written_at" timestamp with time zone,
"duration_seconds" integer,
"abridged" boolean DEFAULT false NOT NULL,
"audible_id" varchar(20),
"comicvine_id" varchar(50),
"chapters" jsonb,
"locked_fields" text[] DEFAULT '{}'::text[] NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_metadata_rating_range_chk" CHECK ("book_metadata"."rating" is null or ("book_metadata"."rating" >= 1 and "book_metadata"."rating" <= 10)),
CONSTRAINT "book_metadata_published_year_range_chk" CHECK ("book_metadata"."published_year" is null or ("book_metadata"."published_year" >= 1000 and "book_metadata"."published_year" <= 2200)),
CONSTRAINT "book_metadata_page_count_nonnegative_chk" CHECK ("book_metadata"."page_count" is null or "book_metadata"."page_count" >= 0),
CONSTRAINT "book_metadata_duration_seconds_nonnegative_chk" CHECK ("book_metadata"."duration_seconds" is null or "book_metadata"."duration_seconds" >= 0),
CONSTRAINT "book_metadata_cover_source_chk" CHECK ("book_metadata"."cover_source" is null or "book_metadata"."cover_source" in ('extracted', 'custom'))
);
--> statement-breakpoint
CREATE TABLE "book_metadata_fetch_queue" (
@@ -339,11 +371,14 @@ CREATE TABLE "book_metadata_fetch_queue" (
"status" varchar(20) DEFAULT 'queued' NOT NULL,
"reason" varchar(50) DEFAULT 'manual_trigger' NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"last_attempt_at" timestamp,
"last_attempt_at" timestamp with time zone,
"last_error" text,
"last_http_status" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_metadata_fetch_queue_status_chk" CHECK ("book_metadata_fetch_queue"."status" in ('queued', 'processing', 'failed')),
CONSTRAINT "book_metadata_fetch_queue_reason_chk" CHECK ("book_metadata_fetch_queue"."reason" in ('event_import', 'manual_trigger', 'manual_retry')),
CONSTRAINT "book_metadata_fetch_queue_attempt_count_nonnegative_chk" CHECK ("book_metadata_fetch_queue"."attempt_count" >= 0)
);
--> statement-breakpoint
CREATE TABLE "book_tags" (
@@ -375,7 +410,7 @@ CREATE TABLE "narrators" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(500) NOT NULL,
"sort_name" varchar(500),
"created_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "narrators_name_unique" UNIQUE("name")
);
--> statement-breakpoint
@@ -388,8 +423,13 @@ CREATE TABLE "scan_jobs" (
"updated_count" integer DEFAULT 0 NOT NULL,
"missing_count" integer DEFAULT 0 NOT NULL,
"error_message" text,
"started_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "scan_jobs_status_chk" CHECK ("scan_jobs"."status" in ('running', 'completed', 'failed')),
CONSTRAINT "scan_jobs_triggered_by_chk" CHECK ("scan_jobs"."triggered_by" in ('manual', 'watcher', 'schedule')),
CONSTRAINT "scan_jobs_added_count_nonnegative_chk" CHECK ("scan_jobs"."added_count" >= 0),
CONSTRAINT "scan_jobs_updated_count_nonnegative_chk" CHECK ("scan_jobs"."updated_count" >= 0),
CONSTRAINT "scan_jobs_missing_count_nonnegative_chk" CHECK ("scan_jobs"."missing_count" >= 0)
);
--> statement-breakpoint
CREATE TABLE "annotations" (
@@ -402,8 +442,9 @@ CREATE TABLE "annotations" (
"style" varchar(20) DEFAULT 'highlight' NOT NULL,
"note" text,
"chapter_title" varchar(500),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "annotations_style_chk" CHECK ("annotations"."style" in ('highlight', 'underline', 'strikethrough', 'squiggly'))
);
--> statement-breakpoint
CREATE TABLE "audiobook_progress" (
@@ -412,8 +453,10 @@ CREATE TABLE "audiobook_progress" (
"percentage" real DEFAULT 0 NOT NULL,
"current_file_id" integer NOT NULL,
"position_seconds" real DEFAULT 0 NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "audiobook_progress_user_id_book_id_pk" PRIMARY KEY("user_id","book_id")
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "audiobook_progress_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
CONSTRAINT "audiobook_progress_percentage_range_chk" CHECK ("audiobook_progress"."percentage" >= 0 and "audiobook_progress"."percentage" <= 100),
CONSTRAINT "audiobook_progress_position_seconds_nonnegative_chk" CHECK ("audiobook_progress"."position_seconds" >= 0)
);
--> statement-breakpoint
CREATE TABLE "bookmarks" (
@@ -423,7 +466,7 @@ CREATE TABLE "bookmarks" (
"cfi" varchar(2000),
"title" varchar(500) NOT NULL,
"position_seconds" real,
"created_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reader_default_preferences" (
@@ -431,7 +474,8 @@ CREATE TABLE "reader_default_preferences" (
"user_id" integer NOT NULL,
"format_group" varchar(10) NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "reader_default_preferences_format_group_chk" CHECK ("reader_default_preferences"."format_group" in ('epub', 'pdf', 'cbx', 'audio'))
);
--> statement-breakpoint
CREATE TABLE "reader_preferences" (
@@ -439,7 +483,7 @@ CREATE TABLE "reader_preferences" (
"user_id" integer NOT NULL,
"book_file_id" integer NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reading_progress" (
@@ -449,8 +493,11 @@ CREATE TABLE "reading_progress" (
"cfi" varchar(2000),
"page_number" integer,
"position_seconds" real,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id")
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id"),
CONSTRAINT "reading_progress_percentage_range_chk" CHECK ("reading_progress"."percentage" >= 0 and "reading_progress"."percentage" <= 100),
CONSTRAINT "reading_progress_page_number_nonnegative_chk" CHECK ("reading_progress"."page_number" is null or "reading_progress"."page_number" >= 0),
CONSTRAINT "reading_progress_position_seconds_nonnegative_chk" CHECK ("reading_progress"."position_seconds" is null or "reading_progress"."position_seconds" >= 0)
);
--> statement-breakpoint
CREATE TABLE "reading_sessions" (
@@ -458,11 +505,14 @@ CREATE TABLE "reading_sessions" (
"user_id" integer NOT NULL,
"book_file_id" integer NOT NULL,
"session_id" varchar(64) NOT NULL,
"started_at" timestamp NOT NULL,
"ended_at" timestamp NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"ended_at" timestamp with time zone NOT NULL,
"duration_seconds" integer NOT NULL,
"progress_delta" real,
"end_progress" real
"end_progress" real,
CONSTRAINT "reading_sessions_duration_seconds_nonnegative_chk" CHECK ("reading_sessions"."duration_seconds" >= 0),
CONSTRAINT "reading_sessions_end_progress_range_chk" CHECK ("reading_sessions"."end_progress" is null or ("reading_sessions"."end_progress" >= 0 and "reading_sessions"."end_progress" <= 100)),
CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at")
);
--> statement-breakpoint
CREATE TABLE "user_book_status" (
@@ -470,10 +520,13 @@ CREATE TABLE "user_book_status" (
"book_id" integer NOT NULL,
"status" varchar(20) DEFAULT 'unread' NOT NULL,
"source" varchar(10) DEFAULT 'auto' NOT NULL,
"started_at" timestamp,
"finished_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_book_status_user_id_book_id_pk" PRIMARY KEY("user_id","book_id")
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_book_status_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
CONSTRAINT "user_book_status_status_chk" CHECK ("user_book_status"."status" in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')),
CONSTRAINT "user_book_status_source_chk" CHECK ("user_book_status"."source" in ('auto', 'manual')),
CONSTRAINT "user_book_status_finished_after_started_chk" CHECK ("user_book_status"."finished_at" is null or "user_book_status"."started_at" is null or "user_book_status"."finished_at" >= "user_book_status"."started_at")
);
--> statement-breakpoint
CREATE TABLE "user_reading_daily_stats" (
@@ -483,15 +536,17 @@ CREATE TABLE "user_reading_daily_stats" (
"reading_seconds" integer DEFAULT 0 NOT NULL,
"progress_delta" real DEFAULT 0 NOT NULL,
"sessions_count" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_reading_daily_stats_user_id_library_id_day_pk" PRIMARY KEY("user_id","library_id","day")
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_reading_daily_stats_user_id_library_id_day_pk" PRIMARY KEY("user_id","library_id","day"),
CONSTRAINT "user_reading_daily_stats_reading_seconds_nonnegative_chk" CHECK ("user_reading_daily_stats"."reading_seconds" >= 0),
CONSTRAINT "user_reading_daily_stats_sessions_count_nonnegative_chk" CHECK ("user_reading_daily_stats"."sessions_count" >= 0)
);
--> statement-breakpoint
CREATE TABLE "oidc_group_mappings" (
"id" serial PRIMARY KEY NOT NULL,
"oidc_group_claim" text NOT NULL,
"permission_name" varchar(100),
"created_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "oidc_group_mappings_oidc_group_claim_unique" UNIQUE("oidc_group_claim")
);
--> statement-breakpoint
@@ -503,7 +558,8 @@ CREATE TABLE "oidc_sessions" (
"oidc_session_id" text,
"id_token_hint" text,
"revoked" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "opds_users" (
@@ -512,8 +568,8 @@ CREATE TABLE "opds_users" (
"username" varchar(100) NOT NULL,
"password_hash" varchar(255) NOT NULL,
"sort_order" "opds_sort_order" DEFAULT 'recent' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "opds_users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
@@ -522,16 +578,16 @@ CREATE TABLE "kobo_devices" (
"user_id" integer NOT NULL,
"name" varchar(255) NOT NULL,
"token" varchar(64) NOT NULL,
"last_seen_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_devices_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "kobo_library_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_library_snapshots_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
@@ -546,8 +602,8 @@ CREATE TABLE "kobo_reading_states" (
"current_bookmark" jsonb,
"statistics" jsonb,
"status_info" jsonb,
"progress_synced_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL,
"progress_synced_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_reading_states_user_id_book_id_unique" UNIQUE("user_id","book_id")
);
--> statement-breakpoint
@@ -572,9 +628,12 @@ CREATE TABLE "kobo_sync_settings" (
"two_way_progress_sync" boolean DEFAULT false NOT NULL,
"force_enable_hyphenation" boolean DEFAULT false NOT NULL,
"kepub_conversion_limit_mb" integer DEFAULT 100 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "kobo_sync_settings_user_id_unique" UNIQUE("user_id")
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_sync_settings_user_id_unique" UNIQUE("user_id"),
CONSTRAINT "kobo_sync_settings_reading_threshold_range_chk" CHECK ("kobo_sync_settings"."reading_threshold" >= 0 and "kobo_sync_settings"."reading_threshold" <= 100),
CONSTRAINT "kobo_sync_settings_finished_threshold_range_chk" CHECK ("kobo_sync_settings"."finished_threshold" >= 0 and "kobo_sync_settings"."finished_threshold" <= 100),
CONSTRAINT "kobo_sync_settings_conversion_limit_nonnegative_chk" CHECK ("kobo_sync_settings"."kepub_conversion_limit_mb" >= 0)
);
--> statement-breakpoint
CREATE TABLE "book_bucket_files" (
@@ -593,10 +652,12 @@ CREATE TABLE "book_bucket_files" (
"confidence" integer,
"fetched_metadata_sources" jsonb,
"error_message" text,
"metadata_edited_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "book_bucket_files_absolute_path_unique" UNIQUE("absolute_path")
"metadata_edited_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_bucket_files_absolute_path_unique" UNIQUE("absolute_path"),
CONSTRAINT "book_bucket_files_status_chk" CHECK ("book_bucket_files"."status" in ('pending', 'extracting', 'fetching', 'ready', 'error')),
CONSTRAINT "book_bucket_files_confidence_range_chk" CHECK ("book_bucket_files"."confidence" is null or ("book_bucket_files"."confidence" >= 0 and "book_bucket_files"."confidence" <= 100))
);
--> statement-breakpoint
CREATE TABLE "file_write_log" (
@@ -610,7 +671,10 @@ CREATE TABLE "file_write_log" (
"error_message" text,
"duration_ms" integer,
"triggered_by" varchar(10) NOT NULL,
"written_at" timestamp DEFAULT now() NOT NULL
"written_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "file_write_log_status_chk" CHECK ("file_write_log"."status" in ('success', 'skipped', 'failed')),
CONSTRAINT "file_write_log_triggered_by_chk" CHECK ("file_write_log"."triggered_by" in ('auto', 'sync')),
CONSTRAINT "file_write_log_duration_ms_nonnegative_chk" CHECK ("file_write_log"."duration_ms" is null or "file_write_log"."duration_ms" >= 0)
);
--> statement-breakpoint
CREATE TABLE "email_templates" (
@@ -621,8 +685,8 @@ CREATE TABLE "email_templates" (
"body_text" text NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"is_system" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_templates_user_id_name_unique" UNIQUE("user_id","name")
);
--> statement-breakpoint
@@ -642,15 +706,17 @@ CREATE TABLE "email_providers" (
"is_default" boolean DEFAULT false NOT NULL,
"is_shared" boolean DEFAULT false NOT NULL,
"is_system_provider" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name")
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name"),
CONSTRAINT "email_providers_port_range_chk" CHECK ("email_providers"."port" >= 1 and "email_providers"."port" <= 65535)
);
--> statement-breakpoint
CREATE TABLE "email_recipient_group_members" (
"user_id" integer NOT NULL,
"group_id" integer NOT NULL,
"recipient_id" integer NOT NULL,
CONSTRAINT "email_recipient_group_members_group_id_recipient_id_unique" UNIQUE("group_id","recipient_id")
CONSTRAINT "email_recipient_group_members_group_id_recipient_id_pk" PRIMARY KEY("group_id","recipient_id")
);
--> statement-breakpoint
CREATE TABLE "email_recipient_groups" (
@@ -658,8 +724,8 @@ CREATE TABLE "email_recipient_groups" (
"user_id" integer NOT NULL,
"name" varchar(255) NOT NULL,
"default_template_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_recipient_groups_user_id_name_unique" UNIQUE("user_id","name")
);
--> statement-breakpoint
@@ -672,9 +738,11 @@ CREATE TABLE "email_recipients" (
"device_type" varchar(20),
"preferred_format" varchar(20),
"default_template_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "email_recipients_user_id_email_unique" UNIQUE("user_id","email")
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_recipients_user_id_email_unique" UNIQUE("user_id","email"),
CONSTRAINT "email_recipients_device_type_chk" CHECK ("email_recipients"."device_type" is null or "email_recipients"."device_type" in ('kindle', 'kobo', 'other')),
CONSTRAINT "email_recipients_preferred_format_chk" CHECK ("email_recipients"."preferred_format" is null or "email_recipients"."preferred_format" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr'))
);
--> statement-breakpoint
CREATE TABLE "email_preferences" (
@@ -683,8 +751,8 @@ CREATE TABLE "email_preferences" (
"default_provider_id" integer,
"default_recipient_id" integer,
"default_template_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_preferences_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
@@ -700,11 +768,14 @@ CREATE TABLE "email_send_log" (
"subject" text,
"status" varchar(20) DEFAULT 'pending' NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"next_retry_at" timestamp,
"next_retry_at" timestamp with time zone,
"error_message" text,
"sent_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
"sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_send_log_status_chk" CHECK ("email_send_log"."status" in ('pending', 'sent', 'failed')),
CONSTRAINT "email_send_log_attempt_count_nonnegative_chk" CHECK ("email_send_log"."attempt_count" >= 0),
CONSTRAINT "email_send_log_sent_after_created_chk" CHECK ("email_send_log"."sent_at" is null or "email_send_log"."sent_at" >= "email_send_log"."created_at")
);
--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
@@ -717,6 +788,7 @@ ALTER TABLE "comic_metadata" ADD CONSTRAINT "comic_metadata_book_id_books_id_fk"
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_source_id_migration_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."migration_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_profile_id_migration_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."migration_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_plan_artifacts" ADD CONSTRAINT "migration_plan_artifacts_profile_source_fk" FOREIGN KEY ("profile_id","source_id") REFERENCES "public"."migration_profiles"("id","source_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_profiles" ADD CONSTRAINT "migration_profiles_source_id_migration_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."migration_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_profiles" ADD CONSTRAINT "migration_profiles_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_run_metrics" ADD CONSTRAINT "migration_run_metrics_run_id_migration_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."migration_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -724,13 +796,17 @@ ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_source_id_migration_
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_profile_id_migration_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."migration_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk" FOREIGN KEY ("plan_artifact_id") REFERENCES "public"."migration_plan_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_triggered_by_user_id_users_id_fk" FOREIGN KEY ("triggered_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_profile_source_fk" FOREIGN KEY ("profile_id","source_id") REFERENCES "public"."migration_profiles"("id","source_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD CONSTRAINT "migration_runs_plan_artifact_source_profile_fk" FOREIGN KEY ("plan_artifact_id","source_id","profile_id") REFERENCES "public"."migration_plan_artifacts"("id","source_id","profile_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "migration_sources" ADD CONSTRAINT "migration_sources_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_folder_consistency_fk" FOREIGN KEY ("book_id","library_folder_id") REFERENCES "public"."books"("id","library_folder_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_primary_file_id_book_files_id_fk" FOREIGN KEY ("primary_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_library_fk" FOREIGN KEY ("library_folder_id","library_id") REFERENCES "public"."library_folders"("id","library_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -781,8 +857,11 @@ ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_file_id_book_fi
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_templates" ADD CONSTRAINT "email_templates_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_providers" ADD CONSTRAINT "email_providers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_group_id_email_recipient_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."email_recipient_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_recipient_id_email_recipients_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."email_recipients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_group_user_fk" FOREIGN KEY ("group_id","user_id") REFERENCES "public"."email_recipient_groups"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_group_members" ADD CONSTRAINT "email_recipient_group_members_recipient_user_fk" FOREIGN KEY ("recipient_id","user_id") REFERENCES "public"."email_recipients"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_groups" ADD CONSTRAINT "email_recipient_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipient_groups" ADD CONSTRAINT "email_recipient_groups_default_template_id_email_templates_id_fk" FOREIGN KEY ("default_template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_recipients" ADD CONSTRAINT "email_recipients_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -791,6 +870,7 @@ ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_user_id_users_
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_provider_id_email_providers_id_fk" FOREIGN KEY ("default_provider_id") REFERENCES "public"."email_providers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_recipient_id_email_recipients_id_fk" FOREIGN KEY ("default_recipient_id") REFERENCES "public"."email_recipients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_template_id_email_templates_id_fk" FOREIGN KEY ("default_template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_default_recipient_owner_fk" FOREIGN KEY ("default_recipient_id","user_id") REFERENCES "public"."email_recipients"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
@@ -802,11 +882,18 @@ CREATE INDEX "idx_audit_action" ON "audit_log" USING btree ("action");--> statem
CREATE INDEX "idx_audit_ip" ON "audit_log" USING btree ("ip");--> statement-breakpoint
CREATE INDEX "idx_audit_created_at" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "users_username_lower_uidx" ON "users" USING btree (lower("username"));--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_lower_uidx" ON "users" USING btree (lower("email")) WHERE "users"."email" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "users_oidc_subject_issuer_uidx" ON "users" USING btree ("oidc_subject","oidc_issuer") WHERE "users"."oidc_subject" is not null and "users"."oidc_issuer" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "migration_plan_artifacts_plan_hash_uidx" ON "migration_plan_artifacts" USING btree ("plan_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "migration_plan_artifacts_id_source_profile_uidx" ON "migration_plan_artifacts" USING btree ("id","source_id","profile_id");--> statement-breakpoint
CREATE INDEX "migration_plan_artifacts_source_id_idx" ON "migration_plan_artifacts" USING btree ("source_id");--> statement-breakpoint
CREATE INDEX "migration_plan_artifacts_profile_id_idx" ON "migration_plan_artifacts" USING btree ("profile_id");--> statement-breakpoint
CREATE UNIQUE INDEX "migration_profiles_source_name_version_uidx" ON "migration_profiles" USING btree ("source_id","name","version");--> statement-breakpoint
CREATE UNIQUE INDEX "migration_profiles_id_source_id_uidx" ON "migration_profiles" USING btree ("id","source_id");--> statement-breakpoint
CREATE INDEX "migration_profiles_source_id_idx" ON "migration_profiles" USING btree ("source_id");--> statement-breakpoint
CREATE UNIQUE INDEX "migration_run_metrics_run_stage_entity_uidx" ON "migration_run_metrics" USING btree ("run_id","stage","entity_type");--> statement-breakpoint
CREATE INDEX "migration_run_metrics_run_id_idx" ON "migration_run_metrics" USING btree ("run_id");--> statement-breakpoint
@@ -814,8 +901,26 @@ CREATE INDEX "migration_runs_source_target_state_idx" ON "migration_runs" USING
CREATE INDEX "migration_runs_state_idx" ON "migration_runs" USING btree ("state");--> statement-breakpoint
CREATE UNIQUE INDEX "migration_sources_type_name_uidx" ON "migration_sources" USING btree ("type","name");--> statement-breakpoint
CREATE INDEX "migration_sources_created_by_user_id_idx" ON "migration_sources" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "libraries_name_lower_uidx" ON "libraries" USING btree (lower("name"));--> statement-breakpoint
CREATE INDEX "library_folders_library_id_idx" ON "library_folders" USING btree ("library_id");--> statement-breakpoint
CREATE UNIQUE INDEX "library_folders_library_path_uidx" ON "library_folders" USING btree ("library_id","path");--> statement-breakpoint
CREATE UNIQUE INDEX "library_folders_id_library_id_uidx" ON "library_folders" USING btree ("id","library_id");--> statement-breakpoint
CREATE UNIQUE INDEX "book_files_absolute_path_uidx" ON "book_files" USING btree ("absolute_path");--> statement-breakpoint
CREATE INDEX "book_files_book_id_idx" ON "book_files" USING btree ("book_id");--> statement-breakpoint
CREATE INDEX "book_files_library_folder_id_idx" ON "book_files" USING btree ("library_folder_id");--> statement-breakpoint
CREATE INDEX "book_files_hash_idx" ON "book_files" USING btree ("hash");--> statement-breakpoint
CREATE INDEX "book_files_ino_idx" ON "book_files" USING btree ("ino");--> statement-breakpoint
CREATE INDEX "book_files_format_idx" ON "book_files" USING btree ("format");--> statement-breakpoint
CREATE INDEX "book_files_library_folder_hash_idx" ON "book_files" USING btree ("library_folder_id","hash");--> statement-breakpoint
CREATE INDEX "book_files_library_folder_ino_idx" ON "book_files" USING btree ("library_folder_id","ino");--> statement-breakpoint
CREATE UNIQUE INDEX "books_library_id_folder_path_idx" ON "books" USING btree ("library_id","folder_path");--> statement-breakpoint
CREATE UNIQUE INDEX "books_id_library_folder_id_uidx" ON "books" USING btree ("id","library_folder_id");--> statement-breakpoint
CREATE INDEX "books_primary_file_id_idx" ON "books" USING btree ("primary_file_id");--> statement-breakpoint
CREATE INDEX "books_library_status_idx" ON "books" USING btree ("library_id","status");--> statement-breakpoint
CREATE INDEX "books_library_added_at_idx" ON "books" USING btree ("library_id","added_at" desc);--> statement-breakpoint
CREATE INDEX "collection_books_book_id_idx" ON "collection_books" USING btree ("book_id");--> statement-breakpoint
CREATE UNIQUE INDEX "collections_user_name_uidx" ON "collections" USING btree ("user_id","name");--> statement-breakpoint
CREATE INDEX "collections_user_display_name_idx" ON "collections" USING btree ("user_id","display_order","name");--> statement-breakpoint
CREATE INDEX "author_enrichment_queue_status_next_attempt_idx" ON "author_enrichment_queue" USING btree ("status","next_attempt_at");--> statement-breakpoint
CREATE INDEX "author_enrichment_queue_next_attempt_idx" ON "author_enrichment_queue" USING btree ("next_attempt_at");--> statement-breakpoint
CREATE UNIQUE INDEX "author_enrichment_queue_single_processing_idx" ON "author_enrichment_queue" USING btree ("status") WHERE "author_enrichment_queue"."status" = 'processing';--> statement-breakpoint
@@ -825,14 +930,19 @@ CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_publisher_trgm_idx" ON "book_metadata" USING gin ("publisher" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_language_idx" ON "book_metadata" USING btree ("language");--> statement-breakpoint
CREATE INDEX "bm_published_year_idx" ON "book_metadata" USING btree ("published_year");--> statement-breakpoint
CREATE INDEX "bm_series_name_index_idx" ON "book_metadata" USING btree ("series_name","series_index");--> statement-breakpoint
CREATE INDEX "bmfq_status_idx" ON "book_metadata_fetch_queue" USING btree ("status");--> statement-breakpoint
CREATE INDEX "bmfq_created_at_idx" ON "book_metadata_fetch_queue" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "book_narrators_narrator_id_idx" ON "book_narrators" USING btree ("narrator_id");--> statement-breakpoint
CREATE INDEX "narrators_name_trgm_idx" ON "narrators" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "scan_jobs_library_status_idx" ON "scan_jobs" USING btree ("library_id","status");--> statement-breakpoint
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "annotations_user_book_idx" ON "annotations" USING btree ("user_id","book_id");--> statement-breakpoint
CREATE INDEX "abp_user_id_idx" ON "audiobook_progress" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "bookmarks_user_book_idx" ON "bookmarks" USING btree ("user_id","book_id");--> statement-breakpoint
CREATE UNIQUE INDEX "bookmarks_user_book_cfi_uidx" ON "bookmarks" USING btree ("user_id","book_id","cfi") WHERE "bookmarks"."cfi" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "bookmarks_user_book_pos_uidx" ON "bookmarks" USING btree ("user_id","book_id","position_seconds") WHERE "bookmarks"."position_seconds" is not null and "bookmarks"."cfi" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint
CREATE UNIQUE INDEX "rp_user_file_idx" ON "reader_preferences" USING btree ("user_id","book_file_id");--> statement-breakpoint
CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");--> statement-breakpoint
@@ -843,10 +953,23 @@ CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id
CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint
CREATE INDEX "urds_user_library_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","library_id","day");--> statement-breakpoint
CREATE INDEX "oidc_sessions_user_id_idx" ON "oidc_sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "oidc_sessions_user_active_idx" ON "oidc_sessions" USING btree ("user_id") WHERE "oidc_sessions"."revoked" = false;--> statement-breakpoint
CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint
CREATE INDEX "oidc_sessions_expires_at_idx" ON "oidc_sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "opds_users_username_lower_uidx" ON "opds_users" USING btree (lower("username"));--> statement-breakpoint
CREATE INDEX "kobo_devices_user_id_idx" ON "kobo_devices" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "book_bucket_files_status_idx" ON "book_bucket_files" USING btree ("status");--> statement-breakpoint
CREATE INDEX "fwl_book_id_idx" ON "file_write_log" USING btree ("book_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
CREATE INDEX "fwl_book_id_written_at_idx" ON "file_write_log" USING btree ("book_id","written_at");--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_one_default_per_user_uidx" ON "email_templates" USING btree ("user_id") WHERE "email_templates"."is_default" = true and "email_templates"."user_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "email_providers_id_user_id_uidx" ON "email_providers" USING btree ("id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_providers_one_default_per_user_uidx" ON "email_providers" USING btree ("user_id") WHERE "email_providers"."is_default" = true and "email_providers"."user_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "email_providers_one_system_provider_uidx" ON "email_providers" USING btree ("is_system_provider") WHERE "email_providers"."is_system_provider" = true;--> statement-breakpoint
CREATE UNIQUE INDEX "email_recipient_groups_id_user_id_uidx" ON "email_recipient_groups" USING btree ("id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_recipients_user_email_lower_uidx" ON "email_recipients" USING btree ("user_id",lower("email"));--> statement-breakpoint
CREATE UNIQUE INDEX "email_recipients_one_default_per_user_uidx" ON "email_recipients" USING btree ("user_id") WHERE "email_recipients"."is_default" = true;--> statement-breakpoint
CREATE UNIQUE INDEX "email_recipients_id_user_id_uidx" ON "email_recipients" USING btree ("id","user_id");--> statement-breakpoint
CREATE INDEX "email_send_log_user_created_at_idx" ON "email_send_log" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "email_send_log_created_at_idx" ON "email_send_log" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "email_send_log_status_next_retry_idx" ON "email_send_log" USING btree ("status","next_retry_at");
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1775440071752,
"when": 1775479477769,
"tag": "0000_initial",
"breakpoints": true
}
+1 -1
View File
@@ -14,7 +14,7 @@ export const auditLog = pgTable(
description: text('description').notNull(),
ip: varchar('ip', { length: 45 }),
meta: jsonb('meta'),
createdAt: timestamp('created_at').defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index('idx_audit_user_id').on(t.userId),
+71 -33
View File
@@ -1,32 +1,62 @@
import { boolean, index, integer, jsonb, pgEnum, pgTable, primaryKey, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
serial,
text,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core';
import { libraries } from './libraries';
export const userAvatarSourceEnum = pgEnum('user_avatar_source', ['none', 'external', 'uploaded']);
export const users = pgTable('users', {
id: serial('id').primaryKey(),
username: varchar('username', { length: 100 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
active: boolean('active').notNull().default(true),
isSuperuser: boolean('is_superuser').notNull().default(false),
isDefaultPassword: boolean('is_default_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(1),
settings: jsonb('settings').notNull().default({}),
oidcSubject: text('oidc_subject'),
oidcIssuer: text('oidc_issuer'),
avatarUrl: text('avatar_url'),
avatarSource: userAvatarSourceEnum('avatar_source').notNull().default('none'),
avatarVersion: integer('avatar_version').notNull().default(0),
provisioningMethod: varchar('provisioning_method', { length: 20 }).notNull().default('local'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
});
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
username: varchar('username', { length: 100 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
active: boolean('active').notNull().default(true),
isSuperuser: boolean('is_superuser').notNull().default(false),
isDefaultPassword: boolean('is_default_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(1),
settings: jsonb('settings').notNull().default({}),
oidcSubject: text('oidc_subject'),
oidcIssuer: text('oidc_issuer'),
avatarUrl: text('avatar_url'),
avatarSource: userAvatarSourceEnum('avatar_source').notNull().default('none'),
avatarVersion: integer('avatar_version').notNull().default(0),
provisioningMethod: varchar('provisioning_method', { length: 20 }).notNull().default('local'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('users_username_lower_uidx').on(sql`lower(${t.username})`),
uniqueIndex('users_email_lower_uidx')
.on(sql`lower(${t.email})`)
.where(sql`${t.email} is not null`),
uniqueIndex('users_oidc_subject_issuer_uidx')
.on(t.oidcSubject, t.oidcIssuer)
.where(sql`${t.oidcSubject} is not null and ${t.oidcIssuer} is not null`),
check('users_provisioning_method_chk', sql`${t.provisioningMethod} in ('local', 'manual', 'oidc')`),
check('users_token_version_nonnegative_chk', sql`${t.tokenVersion} >= 0`),
check('users_avatar_version_nonnegative_chk', sql`${t.avatarVersion} >= 0`),
],
);
export const userPermissions = pgTable(
'user_permissions',
@@ -63,11 +93,15 @@ export const refreshTokens = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('refresh_tokens_user_id_idx').on(t.userId)],
(t) => [
index('refresh_tokens_user_id_idx').on(t.userId),
index('refresh_tokens_expires_at_idx').on(t.expiresAt),
check('refresh_tokens_expires_after_created_chk', sql`${t.expiresAt} > ${t.createdAt}`),
],
);
export const passwordResetTokens = pgTable(
@@ -78,18 +112,22 @@ export const passwordResetTokens = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('password_reset_tokens_user_id_idx').on(t.userId)],
(t) => [
index('password_reset_tokens_user_id_idx').on(t.userId),
index('password_reset_tokens_expires_at_idx').on(t.expiresAt),
check('password_reset_tokens_expires_after_created_chk', sql`${t.expiresAt} > ${t.createdAt}`),
],
);
export const appSettings = pgTable('app_settings', {
id: serial('id').primaryKey(),
key: varchar('key', { length: 100 }).notNull().unique(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
+10 -5
View File
@@ -1,4 +1,5 @@
import { bigint, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { bigint, check, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import type { BookBucketMetadata } from '@projectx/types';
import { libraries, libraryFolders } from './libraries';
@@ -21,14 +22,18 @@ export const bookBucketFiles = pgTable(
confidence: integer('confidence'),
fetchedMetadataSources: jsonb('fetched_metadata_sources').$type<Partial<Record<keyof BookBucketMetadata, string>>>(),
errorMessage: text('error_message'),
metadataEditedAt: timestamp('metadata_edited_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
metadataEditedAt: timestamp('metadata_edited_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index('book_bucket_files_status_idx').on(t.status)],
(t) => [
index('book_bucket_files_status_idx').on(t.status),
check('book_bucket_files_status_chk', sql`${t.status} in ('pending', 'extracting', 'fetching', 'ready', 'error')`),
check('book_bucket_files_confidence_range_chk', sql`${t.confidence} is null or (${t.confidence} >= 0 and ${t.confidence} <= 100)`),
],
);
export type BookBucketFileRow = typeof bookBucketFiles.$inferSelect;
+62 -28
View File
@@ -1,4 +1,5 @@
import { bigint, index, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { bigint, check, foreignKey, index, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { libraryFolders, libraries } from './libraries';
@@ -15,39 +16,72 @@ export const books = pgTable(
primaryFileId: integer('primary_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('present'),
addedAt: timestamp('added_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [uniqueIndex('books_library_id_folder_path_idx').on(t.libraryId, t.folderPath), index('books_primary_file_id_idx').on(t.primaryFileId)],
(t) => [
uniqueIndex('books_library_id_folder_path_idx').on(t.libraryId, t.folderPath),
uniqueIndex('books_id_library_folder_id_uidx').on(t.id, t.libraryFolderId),
index('books_primary_file_id_idx').on(t.primaryFileId),
index('books_library_status_idx').on(t.libraryId, t.status),
index('books_library_added_at_idx').on(t.libraryId, sql`${t.addedAt} desc`),
foreignKey({
columns: [t.libraryFolderId, t.libraryId],
foreignColumns: [libraryFolders.id, libraryFolders.libraryId],
name: 'books_library_folder_library_fk',
}),
check('books_status_chk', sql`${t.status} in ('present', 'missing')`),
],
);
export const bookFiles = pgTable('book_files', {
id: serial('id').primaryKey(),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
libraryFolderId: integer('library_folder_id')
.notNull()
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
relPath: varchar('rel_path', { length: 4096 }),
ino: bigint('ino', { mode: 'number' }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }),
mtime: timestamp('mtime'),
hash: varchar('hash', { length: 64 }),
format: varchar('format', { length: 20 }),
role: varchar('role', { length: 20 }).notNull().default('content'),
sortOrder: integer('sort_order'),
durationSeconds: integer('duration_seconds'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export const bookFiles = pgTable(
'book_files',
{
id: serial('id').primaryKey(),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
libraryFolderId: integer('library_folder_id')
.notNull()
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
relPath: varchar('rel_path', { length: 4096 }),
ino: bigint('ino', { mode: 'number' }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }),
mtime: timestamp('mtime', { withTimezone: true }),
hash: varchar('hash', { length: 64 }),
format: varchar('format', { length: 20 }),
role: varchar('role', { length: 20 }).notNull().default('content'),
sortOrder: integer('sort_order'),
durationSeconds: integer('duration_seconds'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('book_files_absolute_path_uidx').on(t.absolutePath),
index('book_files_book_id_idx').on(t.bookId),
index('book_files_library_folder_id_idx').on(t.libraryFolderId),
index('book_files_hash_idx').on(t.hash),
index('book_files_ino_idx').on(t.ino),
index('book_files_format_idx').on(t.format),
index('book_files_library_folder_hash_idx').on(t.libraryFolderId, t.hash),
index('book_files_library_folder_ino_idx').on(t.libraryFolderId, t.ino),
foreignKey({
columns: [t.bookId, t.libraryFolderId],
foreignColumns: [books.id, books.libraryFolderId],
name: 'book_files_book_folder_consistency_fk',
}),
check('book_files_role_chk', sql`${t.role} in ('content', 'cover', 'supplement')`),
check('book_files_size_bytes_nonnegative_chk', sql`${t.sizeBytes} is null or ${t.sizeBytes} >= 0`),
check('book_files_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
],
);
export type Book = typeof books.$inferSelect;
export type NewBook = typeof books.$inferInsert;
+26 -19
View File
@@ -1,24 +1,31 @@
import { boolean, integer, pgTable, primaryKey, serial, text, timestamp } from 'drizzle-orm/pg-core';
import { index, boolean, integer, pgTable, primaryKey, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { books } from './books';
import { users } from './auth';
export const collections = pgTable('collections', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
icon: text('icon'),
description: text('description'),
syncToKobo: boolean('sync_to_kobo').notNull().default(false),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export const collections = pgTable(
'collections',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
icon: text('icon'),
description: text('description'),
syncToKobo: boolean('sync_to_kobo').notNull().default(false),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('collections_user_name_uidx').on(t.userId, t.name),
index('collections_user_display_name_idx').on(t.userId, t.displayOrder, t.name),
],
);
export const collectionBooks = pgTable(
'collection_books',
@@ -29,7 +36,7 @@ export const collectionBooks = pgTable(
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
addedAt: timestamp('added_at').defaultNow().notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] })],
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] }), index('collection_books_book_id_idx').on(table.bookId)],
);
+2 -2
View File
@@ -20,8 +20,8 @@ export const comicMetadata = pgTable(
teams: text('teams').array(),
locations: text('locations').array(),
storyArcs: text('story_arcs').array(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
+26 -16
View File
@@ -1,25 +1,35 @@
import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
import { foreignKey, integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { emailProviders } from './email-providers';
import { emailRecipients } from './email-recipients';
import { emailTemplates } from './email-templates';
export const emailPreferences = pgTable('email_preferences', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.unique()
.references(() => users.id, { onDelete: 'cascade' }),
defaultProviderId: integer('default_provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
defaultRecipientId: integer('default_recipient_id').references(() => emailRecipients.id, { onDelete: 'set null' }),
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export const emailPreferences = pgTable(
'email_preferences',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.unique()
.references(() => users.id, { onDelete: 'cascade' }),
defaultProviderId: integer('default_provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
defaultRecipientId: integer('default_recipient_id').references(() => emailRecipients.id, { onDelete: 'set null' }),
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
foreignKey({
columns: [t.defaultRecipientId, t.userId],
foreignColumns: [emailRecipients.id, emailRecipients.userId],
name: 'email_preferences_default_recipient_owner_fk',
}),
],
);
export type EmailPreferences = typeof emailPreferences.$inferSelect;
export type NewEmailPreferences = typeof emailPreferences.$inferInsert;
+15 -4
View File
@@ -1,4 +1,5 @@
import { boolean, integer, pgTable, serial, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { boolean, check, integer, pgTable, serial, text, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
@@ -20,13 +21,23 @@ export const emailProviders = pgTable(
isDefault: boolean('is_default').notNull().default(false),
isShared: boolean('is_shared').notNull().default(false),
isSystemProvider: boolean('is_system_provider').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [unique().on(t.userId, t.name)],
(t) => [
unique().on(t.userId, t.name),
uniqueIndex('email_providers_id_user_id_uidx').on(t.id, t.userId),
uniqueIndex('email_providers_one_default_per_user_uidx')
.on(t.userId)
.where(sql`${t.isDefault} = true and ${t.userId} is not null`),
uniqueIndex('email_providers_one_system_provider_uidx')
.on(t.isSystemProvider)
.where(sql`${t.isSystemProvider} = true`),
check('email_providers_port_range_chk', sql`${t.port} >= 1 and ${t.port} <= 65535`),
],
);
export type EmailProvider = typeof emailProviders.$inferSelect;
@@ -44,12 +44,15 @@ describe('email-recipients schema', () => {
it('protects group-membership integrity with composite uniqueness and cascade deletes', () => {
const config = getTableConfig(emailRecipientGroupMembers);
const unique = config.uniqueConstraints.map((constraint) => constraint.columns.map((col) => col.name));
const primaryKeys = config.primaryKeys.map((constraint) => constraint.columns.map((col) => col.name));
expect(unique).toContainEqual(['group_id', 'recipient_id']);
expect(primaryKeys).toContainEqual(['group_id', 'recipient_id']);
const foreignKeys = fkByColumn(emailRecipientGroupMembers);
expect(foreignKeys.get('user_id')?.onDelete).toBe('cascade');
expect(foreignKeys.get('group_id')?.onDelete).toBe('cascade');
expect(foreignKeys.get('recipient_id')?.onDelete).toBe('cascade');
expect(foreignKeys.get('group_id,user_id')).toBeDefined();
expect(foreignKeys.get('recipient_id,user_id')).toBeDefined();
});
});
+36 -8
View File
@@ -1,4 +1,5 @@
import { boolean, integer, pgTable, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { boolean, check, foreignKey, integer, pgTable, primaryKey, serial, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { emailTemplates } from './email-templates';
@@ -16,13 +17,25 @@ export const emailRecipients = pgTable(
deviceType: varchar('device_type', { length: 20 }),
preferredFormat: varchar('preferred_format', { length: 20 }),
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [unique().on(t.userId, t.email)],
(t) => [
unique().on(t.userId, t.email),
uniqueIndex('email_recipients_user_email_lower_uidx').on(t.userId, sql`lower(${t.email})`),
uniqueIndex('email_recipients_one_default_per_user_uidx')
.on(t.userId)
.where(sql`${t.isDefault} = true`),
uniqueIndex('email_recipients_id_user_id_uidx').on(t.id, t.userId),
check('email_recipients_device_type_chk', sql`${t.deviceType} is null or ${t.deviceType} in ('kindle', 'kobo', 'other')`),
check(
'email_recipients_preferred_format_chk',
sql`${t.preferredFormat} is null or ${t.preferredFormat} in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')`,
),
],
);
export const emailRecipientGroups = pgTable(
@@ -34,18 +47,21 @@ export const emailRecipientGroups = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
defaultTemplateId: integer('default_template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [unique().on(t.userId, t.name)],
(t) => [unique().on(t.userId, t.name), uniqueIndex('email_recipient_groups_id_user_id_uidx').on(t.id, t.userId)],
);
export const emailRecipientGroupMembers = pgTable(
'email_recipient_group_members',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
groupId: integer('group_id')
.notNull()
.references(() => emailRecipientGroups.id, { onDelete: 'cascade' }),
@@ -53,7 +69,19 @@ export const emailRecipientGroupMembers = pgTable(
.notNull()
.references(() => emailRecipients.id, { onDelete: 'cascade' }),
},
(t) => [unique().on(t.groupId, t.recipientId)],
(t) => [
primaryKey({ columns: [t.groupId, t.recipientId] }),
foreignKey({
columns: [t.groupId, t.userId],
foreignColumns: [emailRecipientGroups.id, emailRecipientGroups.userId],
name: 'email_recipient_group_members_group_user_fk',
}),
foreignKey({
columns: [t.recipientId, t.userId],
foreignColumns: [emailRecipients.id, emailRecipients.userId],
name: 'email_recipient_group_members_recipient_user_fk',
}),
],
);
export type EmailRecipient = typeof emailRecipients.$inferSelect;
+36 -24
View File
@@ -1,33 +1,45 @@
import { integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { books, bookFiles } from './books';
import { emailProviders } from './email-providers';
import { emailTemplates } from './email-templates';
export const emailSendLog = pgTable('email_send_log', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookId: integer('book_id').references(() => books.id, { onDelete: 'set null' }),
bookFileId: integer('book_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
providerId: integer('provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
templateId: integer('template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
toEmail: varchar('to_email', { length: 255 }).notNull(),
toName: varchar('to_name', { length: 255 }),
subject: text('subject'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
attemptCount: integer('attempt_count').notNull().default(0),
nextRetryAt: timestamp('next_retry_at'),
errorMessage: text('error_message'),
sentAt: timestamp('sent_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export const emailSendLog = pgTable(
'email_send_log',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookId: integer('book_id').references(() => books.id, { onDelete: 'set null' }),
bookFileId: integer('book_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
providerId: integer('provider_id').references(() => emailProviders.id, { onDelete: 'set null' }),
templateId: integer('template_id').references(() => emailTemplates.id, { onDelete: 'set null' }),
toEmail: varchar('to_email', { length: 255 }).notNull(),
toName: varchar('to_name', { length: 255 }),
subject: text('subject'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
attemptCount: integer('attempt_count').notNull().default(0),
nextRetryAt: timestamp('next_retry_at', { withTimezone: true }),
errorMessage: text('error_message'),
sentAt: timestamp('sent_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
index('email_send_log_user_created_at_idx').on(t.userId, t.createdAt),
index('email_send_log_created_at_idx').on(t.createdAt),
index('email_send_log_status_next_retry_idx').on(t.status, t.nextRetryAt),
check('email_send_log_status_chk', sql`${t.status} in ('pending', 'sent', 'failed')`),
check('email_send_log_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
check('email_send_log_sent_after_created_chk', sql`${t.sentAt} is null or ${t.sentAt} >= ${t.createdAt}`),
],
);
export type EmailSendLog = typeof emailSendLog.$inferSelect;
export type NewEmailSendLog = typeof emailSendLog.$inferInsert;
+5 -2
View File
@@ -13,14 +13,17 @@ export const emailTemplates = pgTable(
bodyText: text('body_text').notNull(),
isDefault: boolean('is_default').notNull().default(false),
isSystem: boolean('is_system').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
unique().on(t.userId, t.name),
uniqueIndex('email_templates_one_default_per_user_uidx')
.on(t.userId)
.where(sql`${t.isDefault} = true and ${t.userId} is not null`),
uniqueIndex('email_templates_system_name_unique')
.on(t.name)
.where(sql`${t.userId} is null`),
+9 -3
View File
@@ -1,4 +1,5 @@
import { index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { bookFiles, books } from './books';
import { users } from './auth';
@@ -18,9 +19,14 @@ export const fileWriteLog = pgTable(
errorMessage: text('error_message'),
durationMs: integer('duration_ms'),
triggeredBy: varchar('triggered_by', { length: 10 }).notNull(),
writtenAt: timestamp('written_at').defaultNow().notNull(),
writtenAt: timestamp('written_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [index('fwl_book_id_idx').on(t.bookId)],
(t) => [
index('fwl_book_id_written_at_idx').on(t.bookId, t.writtenAt),
check('file_write_log_status_chk', sql`${t.status} in ('success', 'skipped', 'failed')`),
check('file_write_log_triggered_by_chk', sql`${t.triggeredBy} in ('auto', 'sync')`),
check('file_write_log_duration_ms_nonnegative_chk', sql`${t.durationMs} is null or ${t.durationMs} >= 0`),
],
);
export type FileWriteLog = typeof fileWriteLog.$inferSelect;
+46 -33
View File
@@ -1,37 +1,50 @@
import { boolean, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { books } from './books';
import { users } from './auth';
export const koboDevices = pgTable('kobo_devices', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
token: varchar('token', { length: 64 }).notNull().unique(),
lastSeenAt: timestamp('last_seen_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const koboDevices = pgTable(
'kobo_devices',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
token: varchar('token', { length: 64 }).notNull().unique(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [index('kobo_devices_user_id_idx').on(t.userId)],
);
export const koboSyncSettings = pgTable('kobo_sync_settings', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
readingThreshold: real('reading_threshold').notNull().default(1),
finishedThreshold: real('finished_threshold').notNull().default(99),
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export const koboSyncSettings = pgTable(
'kobo_sync_settings',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
readingThreshold: real('reading_threshold').notNull().default(1),
finishedThreshold: real('finished_threshold').notNull().default(99),
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
check('kobo_sync_settings_reading_threshold_range_chk', sql`${t.readingThreshold} >= 0 and ${t.readingThreshold} <= 100`),
check('kobo_sync_settings_finished_threshold_range_chk', sql`${t.finishedThreshold} >= 0 and ${t.finishedThreshold} <= 100`),
check('kobo_sync_settings_conversion_limit_nonnegative_chk', sql`${t.kepubConversionLimitMb} >= 0`),
],
);
export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
id: serial('id').primaryKey(),
@@ -39,8 +52,8 @@ export const koboLibrarySnapshots = pgTable('kobo_library_snapshots', {
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
@@ -82,8 +95,8 @@ export const koboReadingStates = pgTable(
currentBookmark: jsonb('current_bookmark'),
statistics: jsonb('statistics'),
statusInfo: jsonb('status_info'),
progressSyncedAt: timestamp('progress_synced_at'),
updatedAt: timestamp('updated_at')
progressSyncedAt: timestamp('progress_synced_at', { withTimezone: true }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
+2 -2
View File
@@ -16,8 +16,8 @@ export const lenses = pgTable(
defaultSort: jsonb('default_sort').$type<SortSpec[]>().notNull().default([]),
isPublic: boolean('is_public').notNull().default(false),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
+72 -51
View File
@@ -1,66 +1,87 @@
import { boolean, doublePrecision, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { boolean, check, doublePrecision, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { FieldPreferenceOverrides, BookMetadataFetchConfigOverride } from '@projectx/types';
export const libraries = pgTable('libraries', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
icon: varchar('icon', { length: 100 }),
displayOrder: integer('display_order').notNull().default(0),
coverAspectRatio: varchar('cover_aspect_ratio', { length: 10 }).notNull().default('2/3'),
export const libraries = pgTable(
'libraries',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
icon: varchar('icon', { length: 100 }),
displayOrder: integer('display_order').notNull().default(0),
coverAspectRatio: varchar('cover_aspect_ratio', { length: 10 }).notNull().default('2/3'),
// File watching & scheduling
watch: boolean('watch').notNull().default(false),
autoScanCronExpression: text('auto_scan_cron_expression'),
// File watching & scheduling
watch: boolean('watch').notNull().default(false),
autoScanCronExpression: text('auto_scan_cron_expression'),
// Scanner behaviour
metadataPrecedence: jsonb('metadata_precedence')
.$type<string[]>()
.notNull()
.default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']),
formatPriority: jsonb('format_priority')
.$type<string[]>()
.notNull()
.default(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']),
allowedFormats: jsonb('allowed_formats').$type<string[]>().notNull().default([]),
organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('book_per_folder'),
excludePatterns: jsonb('exclude_patterns').$type<string[]>().notNull().default([]),
// Scanner behaviour
metadataPrecedence: jsonb('metadata_precedence')
.$type<string[]>()
.notNull()
.default(['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar']),
formatPriority: jsonb('format_priority')
.$type<string[]>()
.notNull()
.default(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']),
allowedFormats: jsonb('allowed_formats').$type<string[]>().notNull().default([]),
organizationMode: varchar('organization_mode', { length: 20 }).notNull().default('book_per_folder'),
excludePatterns: jsonb('exclude_patterns').$type<string[]>().notNull().default([]),
// Reading progress thresholds
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
// Reading progress thresholds
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
// File naming pattern for uploads (null = use global default)
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
// File naming pattern for uploads (null = use global default)
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
// Metadata fetch preferences override (null = inherit global defaults)
metadataFetchPreferences: jsonb('metadata_fetch_preferences').$type<FieldPreferenceOverrides>(),
// Metadata fetch preferences override (null = inherit global defaults)
metadataFetchPreferences: jsonb('metadata_fetch_preferences').$type<FieldPreferenceOverrides>(),
// Book metadata auto-fetch config override (null = inherit global defaults)
bookMetadataFetchConfig: jsonb('book_metadata_fetch_config').$type<BookMetadataFetchConfigOverride>(),
// Book metadata auto-fetch config override (null = inherit global defaults)
bookMetadataFetchConfig: jsonb('book_metadata_fetch_config').$type<BookMetadataFetchConfigOverride>(),
// Last manual/scheduled run info for this library (null = never run)
bookMetadataFetchLastRunAt: timestamp('book_metadata_fetch_last_run_at'),
bookMetadataFetchLastQueuedCount: integer('book_metadata_fetch_last_queued_count'),
// Last manual/scheduled run info for this library (null = never run)
bookMetadataFetchLastRunAt: timestamp('book_metadata_fetch_last_run_at', { withTimezone: true }),
bookMetadataFetchLastQueuedCount: integer('book_metadata_fetch_last_queued_count'),
// Legacy — kept for scanner compatibility
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
pollInterval: integer('poll_interval_seconds').default(300),
// Legacy - kept for scanner compatibility
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
pollInterval: integer('poll_interval_seconds').default(300),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('libraries_name_lower_uidx').on(sql`lower(${t.name})`),
check('libraries_display_order_nonnegative_chk', sql`${t.displayOrder} >= 0`),
check('libraries_organization_mode_chk', sql`${t.organizationMode} in ('book_per_folder', 'file_per_book')`),
check('libraries_reading_threshold_range_chk', sql`${t.readingThreshold} >= 0 and ${t.readingThreshold} <= 1`),
check('libraries_mark_finished_percent_range_chk', sql`${t.markAsFinishedPercentComplete} >= 0 and ${t.markAsFinishedPercentComplete} <= 100`),
check('libraries_scan_mode_chk', sql`${t.scanMode} in ('auto', 'manual')`),
check('libraries_poll_interval_nonnegative_chk', sql`${t.pollInterval} is null or ${t.pollInterval} >= 0`),
],
);
export const libraryFolders = pgTable('library_folders', {
id: serial('id').primaryKey(),
libraryId: integer('library_id')
.notNull()
.references(() => libraries.id, { onDelete: 'cascade' }),
path: varchar('path', { length: 4096 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const libraryFolders = pgTable(
'library_folders',
{
id: serial('id').primaryKey(),
libraryId: integer('library_id')
.notNull()
.references(() => libraries.id, { onDelete: 'cascade' }),
path: varchar('path', { length: 4096 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index('library_folders_library_id_idx').on(t.libraryId),
uniqueIndex('library_folders_library_path_uidx').on(t.libraryId, t.path),
uniqueIndex('library_folders_id_library_id_uidx').on(t.id, t.libraryId),
],
);
export type Library = typeof libraries.$inferSelect;
export type NewLibrary = typeof libraries.$inferInsert;
+33 -13
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm';
import {
boolean,
check,
customType,
index,
integer,
@@ -56,9 +57,9 @@ export const bookMetadata = pgTable(
openLibraryId: varchar('open_library_id', { length: 50 }),
itunesId: varchar('itunes_id', { length: 50 }),
metadataScore: integer('metadata_score'),
lastMetadataFetchAt: timestamp('last_metadata_fetch_at'),
lastMetadataFetchAt: timestamp('last_metadata_fetch_at', { withTimezone: true }),
embedding: embedding256('embedding'),
lastWrittenAt: timestamp('last_written_at'),
lastWrittenAt: timestamp('last_written_at', { withTimezone: true }),
durationSeconds: integer('duration_seconds'),
abridged: boolean('abridged').notNull().default(false),
audibleId: varchar('audible_id', { length: 20 }),
@@ -68,7 +69,7 @@ export const bookMetadata = pgTable(
.array()
.notNull()
.default(sql`'{}'::text[]`),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
@@ -78,6 +79,13 @@ export const bookMetadata = pgTable(
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
index('bm_publisher_trgm_idx').using('gin', t.publisher.op('gin_trgm_ops')),
index('bm_language_idx').on(t.language),
index('bm_published_year_idx').on(t.publishedYear),
index('bm_series_name_index_idx').on(t.seriesName, t.seriesIndex),
check('book_metadata_rating_range_chk', sql`${t.rating} is null or (${t.rating} >= 1 and ${t.rating} <= 10)`),
check('book_metadata_published_year_range_chk', sql`${t.publishedYear} is null or (${t.publishedYear} >= 1000 and ${t.publishedYear} <= 2200)`),
check('book_metadata_page_count_nonnegative_chk', sql`${t.pageCount} is null or ${t.pageCount} >= 0`),
check('book_metadata_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
check('book_metadata_cover_source_chk', sql`${t.coverSource} is null or ${t.coverSource} in ('extracted', 'custom')`),
],
);
@@ -89,7 +97,7 @@ export const authors = pgTable(
sortName: varchar('sort_name', { length: 500 }),
description: text('description'),
hasPhoto: boolean('has_photo').notNull().default(false),
lastEnrichedAt: timestamp('last_enriched_at'),
lastEnrichedAt: timestamp('last_enriched_at', { withTimezone: true }),
},
(t) => [unique('authors_name_unique').on(t.name), index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops'))],
);
@@ -117,13 +125,13 @@ export const authorEnrichmentQueue = pgTable(
status: varchar('status', { length: 20 }).notNull().default('queued'),
reason: varchar('reason', { length: 50 }).notNull().default('unknown'),
attemptCount: integer('attempt_count').notNull().default(0),
nextAttemptAt: timestamp('next_attempt_at').notNull().defaultNow(),
lastAttemptAt: timestamp('last_attempt_at'),
lastSuccessAt: timestamp('last_success_at'),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).notNull().defaultNow(),
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
lastSuccessAt: timestamp('last_success_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
@@ -134,6 +142,12 @@ export const authorEnrichmentQueue = pgTable(
uniqueIndex('author_enrichment_queue_single_processing_idx')
.on(t.status)
.where(sql`${t.status} = 'processing'`),
check('author_enrichment_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'rate_limited', 'failed', 'done')`),
check(
'author_enrichment_queue_reason_chk',
sql`${t.reason} in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')`,
),
check('author_enrichment_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
],
);
@@ -197,16 +211,22 @@ export const bookMetadataFetchQueue = pgTable(
status: varchar('status', { length: 20 }).notNull().default('queued'),
reason: varchar('reason', { length: 50 }).notNull().default('manual_trigger'),
attemptCount: integer('attempt_count').notNull().default(0),
lastAttemptAt: timestamp('last_attempt_at'),
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index('bmfq_status_idx').on(t.status), index('bmfq_created_at_idx').on(t.createdAt)],
(t) => [
index('bmfq_status_idx').on(t.status),
index('bmfq_created_at_idx').on(t.createdAt),
check('book_metadata_fetch_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'failed')`),
check('book_metadata_fetch_queue_reason_chk', sql`${t.reason} in ('event_import', 'manual_trigger', 'manual_retry')`),
check('book_metadata_fetch_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
],
);
export type BookMetadataFetchQueue = typeof bookMetadataFetchQueue.$inferSelect;
+38 -14
View File
@@ -1,4 +1,4 @@
import { check, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { check, foreignKey, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -11,10 +11,10 @@ export const migrationSources = pgTable(
name: varchar('name', { length: 255 }).notNull(),
connectionConfig: jsonb('connection_config').notNull(),
capabilities: jsonb('capabilities'),
lastValidatedAt: timestamp('last_validated_at'),
lastValidatedAt: timestamp('last_validated_at', { withTimezone: true }),
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
@@ -42,15 +42,17 @@ export const migrationProfiles = pgTable(
.notNull()
.default(sql`'{}'::jsonb`),
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('migration_profiles_source_name_version_uidx').on(t.sourceId, t.name, t.version),
uniqueIndex('migration_profiles_id_source_id_uidx').on(t.id, t.sourceId),
index('migration_profiles_source_id_idx').on(t.sourceId),
check('migration_profiles_version_positive_chk', sql`${t.version} >= 1`),
],
);
@@ -71,16 +73,22 @@ export const migrationPlanArtifacts = pgTable(
sourceData: jsonb('source_data'),
summary: jsonb('summary').notNull(),
createdByUserId: integer('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('migration_plan_artifacts_plan_hash_uidx').on(t.planHash),
uniqueIndex('migration_plan_artifacts_id_source_profile_uidx').on(t.id, t.sourceId, t.profileId),
index('migration_plan_artifacts_source_id_idx').on(t.sourceId),
index('migration_plan_artifacts_profile_id_idx').on(t.profileId),
foreignKey({
columns: [t.profileId, t.sourceId],
foreignColumns: [migrationProfiles.id, migrationProfiles.sourceId],
name: 'migration_plan_artifacts_profile_source_fk',
}),
],
);
@@ -99,11 +107,11 @@ export const migrationRuns = pgTable(
state: varchar('state', { length: 32 }).notNull().default('draft'),
currentStage: varchar('current_stage', { length: 64 }),
triggeredByUserId: integer('triggered_by_user_id').references(() => users.id, { onDelete: 'set null' }),
startedAt: timestamp('started_at'),
endedAt: timestamp('ended_at'),
startedAt: timestamp('started_at', { withTimezone: true }),
endedAt: timestamp('ended_at', { withTimezone: true }),
errorMessage: text('error_message'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
@@ -111,7 +119,18 @@ export const migrationRuns = pgTable(
(t) => [
index('migration_runs_source_target_state_idx').on(t.sourceId, t.targetKey, t.state),
index('migration_runs_state_idx').on(t.state),
foreignKey({
columns: [t.profileId, t.sourceId],
foreignColumns: [migrationProfiles.id, migrationProfiles.sourceId],
name: 'migration_runs_profile_source_fk',
}),
foreignKey({
columns: [t.planArtifactId, t.sourceId, t.profileId],
foreignColumns: [migrationPlanArtifacts.id, migrationPlanArtifacts.sourceId, migrationPlanArtifacts.profileId],
name: 'migration_runs_plan_artifact_source_profile_fk',
}),
check('migration_runs_state_chk', sql`"state" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')`),
check('migration_runs_started_before_ended_chk', sql`${t.endedAt} is null or ${t.startedAt} is null or ${t.endedAt} >= ${t.startedAt}`),
],
);
@@ -129,8 +148,8 @@ export const migrationRunMetrics = pgTable(
skipped: integer('skipped').notNull().default(0),
unresolved: integer('unresolved').notNull().default(0),
failed: integer('failed').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
@@ -138,6 +157,11 @@ export const migrationRunMetrics = pgTable(
(t) => [
uniqueIndex('migration_run_metrics_run_stage_entity_uidx').on(t.runId, t.stage, t.entityType),
index('migration_run_metrics_run_id_idx').on(t.runId),
check('migration_run_metrics_processed_nonnegative_chk', sql`${t.processed} >= 0`),
check('migration_run_metrics_imported_nonnegative_chk', sql`${t.imported} >= 0`),
check('migration_run_metrics_skipped_nonnegative_chk', sql`${t.skipped} >= 0`),
check('migration_run_metrics_unresolved_nonnegative_chk', sql`${t.unresolved} >= 0`),
check('migration_run_metrics_failed_nonnegative_chk', sql`${t.failed} >= 0`),
],
);
+1 -1
View File
@@ -8,7 +8,7 @@ export const narrators = pgTable(
id: serial('id').primaryKey(),
name: varchar('name', { length: 500 }).notNull().unique(),
sortName: varchar('sort_name', { length: 500 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [index('narrators_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops'))],
);
+8 -3
View File
@@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm';
import { boolean, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
@@ -14,12 +15,16 @@ export const oidcSessions = pgTable(
oidcSessionId: text('oidc_session_id'),
idTokenHint: text('id_token_hint'),
revoked: boolean('revoked').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
},
(t) => [
index('oidc_sessions_user_id_idx').on(t.userId),
index('oidc_sessions_user_active_idx')
.on(t.userId)
.where(sql`${t.revoked} = false`),
index('oidc_sessions_subject_issuer_idx').on(t.oidcSubject, t.oidcIssuer),
index('oidc_sessions_sid_idx').on(t.oidcSessionId),
index('oidc_sessions_expires_at_idx').on(t.expiresAt),
],
);
@@ -27,5 +32,5 @@ export const oidcGroupMappings = pgTable('oidc_group_mappings', {
id: serial('id').primaryKey(),
oidcGroupClaim: text('oidc_group_claim').notNull().unique(),
permissionName: varchar('permission_name', { length: 100 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
+20 -15
View File
@@ -1,4 +1,5 @@
import { integer, pgEnum, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { integer, pgEnum, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
@@ -12,17 +13,21 @@ export const opdsSortOrderEnum = pgEnum('opds_sort_order', [
'series_desc',
]);
export const opdsUsers = pgTable('opds_users', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
username: varchar('username', { length: 100 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
sortOrder: opdsSortOrderEnum('sort_order').notNull().default('recent'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
});
export const opdsUsers = pgTable(
'opds_users',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
username: varchar('username', { length: 100 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
sortOrder: opdsSortOrderEnum('sort_order').notNull().default('recent'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [uniqueIndex('opds_users_username_lower_uidx').on(sql`lower(${t.username})`)],
);
+7 -2
View File
@@ -57,18 +57,23 @@ describe('reader schema', () => {
expect(pkColumns).toContainEqual(['user_id', 'library_id', 'day']);
expect(indexNames).toContain('urds_user_day_idx');
expect(indexNames).toContain('urds_user_library_day_idx');
expect(indexNames).not.toContain('urds_user_library_day_idx');
expect(fkMap.get('user_id')?.onDelete).toBe('cascade');
expect(fkMap.get('library_id')?.onDelete).toBe('cascade');
expect(userReadingDailyStats.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date);
});
it('enforces cascade deletes for bookmarks and annotations ownership', () => {
it('enforces cascade deletes and bookmark uniqueness by logical location', () => {
const bookmarkFks = fkByColumn(bookmarks);
const annotationFks = fkByColumn(annotations);
const bookmarkConfig = getTableConfig(bookmarks);
const bookmarkIndexNames = bookmarkConfig.indexes.map((idx) => idx.config.name);
expect(bookmarkFks.get('user_id')?.onDelete).toBe('cascade');
expect(bookmarkFks.get('book_id')?.onDelete).toBe('cascade');
expect(bookmarkIndexNames).toContain('bookmarks_user_book_idx');
expect(bookmarkIndexNames).toContain('bookmarks_user_book_cfi_uidx');
expect(bookmarkIndexNames).toContain('bookmarks_user_book_pos_uidx');
expect(annotationFks.get('user_id')?.onDelete).toBe('cascade');
expect(annotationFks.get('book_id')?.onDelete).toBe('cascade');
expect(annotations.color.default).toBe('yellow');
+62 -21
View File
@@ -1,4 +1,5 @@
import { date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import type { ReadStatus, ReadStatusSource } from '@projectx/types';
import { bookFiles, books } from './books';
@@ -18,14 +19,24 @@ export const userBookStatus = pgTable(
status: varchar('status', { length: 20 }).$type<ReadStatus>().notNull().default('unread'),
// 'auto' (derived from progress) | 'manual' (user-set; never auto-overridden)
source: varchar('source', { length: 10 }).$type<ReadStatusSource>().notNull().default('auto'),
startedAt: timestamp('started_at'),
finishedAt: timestamp('finished_at'),
updatedAt: timestamp('updated_at')
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [primaryKey({ columns: [t.userId, t.bookId] }), index('ubs_user_id_idx').on(t.userId), index('ubs_user_status_idx').on(t.userId, t.status)],
(t) => [
primaryKey({ columns: [t.userId, t.bookId] }),
index('ubs_user_id_idx').on(t.userId),
index('ubs_user_status_idx').on(t.userId, t.status),
check(
'user_book_status_status_chk',
sql`${t.status} in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')`,
),
check('user_book_status_source_chk', sql`${t.source} in ('auto', 'manual')`),
check('user_book_status_finished_after_started_chk', sql`${t.finishedAt} is null or ${t.startedAt} is null or ${t.finishedAt} >= ${t.startedAt}`),
],
);
export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
@@ -47,12 +58,18 @@ export const readingProgress = pgTable(
pageNumber: integer('page_number'),
// Audio: playback position in seconds
positionSeconds: real('position_seconds'),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [primaryKey({ columns: [t.bookFileId, t.userId] }), index('reading_progress_user_id_idx').on(t.userId)],
(t) => [
primaryKey({ columns: [t.bookFileId, t.userId] }),
index('reading_progress_user_id_idx').on(t.userId),
check('reading_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
check('reading_progress_page_number_nonnegative_chk', sql`${t.pageNumber} is null or ${t.pageNumber} >= 0`),
check('reading_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} is null or ${t.positionSeconds} >= 0`),
],
);
export type ReadingProgress = typeof readingProgress.$inferSelect;
@@ -70,8 +87,8 @@ export const readingSessions = pgTable(
.references(() => bookFiles.id, { onDelete: 'cascade' }),
// Client-generated UUID; used for idempotent retries.
sessionId: varchar('session_id', { length: 64 }).notNull(),
startedAt: timestamp('started_at').notNull(),
endedAt: timestamp('ended_at').notNull(),
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
endedAt: timestamp('ended_at', { withTimezone: true }).notNull(),
// Server-computed from endedAt - startedAt; client-provided timestamps are untrusted for duration.
durationSeconds: integer('duration_seconds').notNull(),
// Nullable: CBX with no percentage tracking may omit these.
@@ -83,6 +100,9 @@ export const readingSessions = pgTable(
index('rs_user_started_at_idx').on(t.userId, t.startedAt),
index('rs_book_file_started_at_idx').on(t.bookFileId, t.startedAt),
index('rs_user_book_file_idx').on(t.userId, t.bookFileId),
check('reading_sessions_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} >= 0`),
check('reading_sessions_end_progress_range_chk', sql`${t.endProgress} is null or (${t.endProgress} >= 0 and ${t.endProgress} <= 100)`),
check('reading_sessions_ended_after_started_chk', sql`${t.endedAt} >= ${t.startedAt}`),
],
);
@@ -102,7 +122,7 @@ export const userReadingDailyStats = pgTable(
readingSeconds: integer('reading_seconds').notNull().default(0),
progressDelta: real('progress_delta').notNull().default(0),
sessionsCount: integer('sessions_count').notNull().default(0),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
@@ -110,7 +130,8 @@ export const userReadingDailyStats = pgTable(
(t) => [
primaryKey({ columns: [t.userId, t.libraryId, t.day] }),
index('urds_user_day_idx').on(t.userId, t.day),
index('urds_user_library_day_idx').on(t.userId, t.libraryId, t.day),
check('user_reading_daily_stats_reading_seconds_nonnegative_chk', sql`${t.readingSeconds} >= 0`),
check('user_reading_daily_stats_sessions_count_nonnegative_chk', sql`${t.sessionsCount} >= 0`),
],
);
@@ -131,12 +152,17 @@ export const audiobookProgress = pgTable(
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
positionSeconds: real('position_seconds').notNull().default(0),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [primaryKey({ columns: [t.userId, t.bookId] }), index('abp_user_id_idx').on(t.userId)],
(t) => [
primaryKey({ columns: [t.userId, t.bookId] }),
index('abp_user_id_idx').on(t.userId),
check('audiobook_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
check('audiobook_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} >= 0`),
],
);
export type AudiobookProgress = typeof audiobookProgress.$inferSelect;
@@ -157,9 +183,17 @@ export const bookmarks = pgTable(
title: varchar('title', { length: 500 }).notNull(),
// Audio: absolute book position in seconds (sum of preceding file durations + offset).
positionSeconds: real('position_seconds'),
createdAt: timestamp('created_at').defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [index('bookmarks_user_id_idx').on(t.userId)],
(t) => [
index('bookmarks_user_book_idx').on(t.userId, t.bookId),
uniqueIndex('bookmarks_user_book_cfi_uidx')
.on(t.userId, t.bookId, t.cfi)
.where(sql`${t.cfi} is not null`),
uniqueIndex('bookmarks_user_book_pos_uidx')
.on(t.userId, t.bookId, t.positionSeconds)
.where(sql`${t.positionSeconds} is not null and ${t.cfi} is null`),
],
);
export type BookmarkRow = typeof bookmarks.$inferSelect;
@@ -181,13 +215,17 @@ export const annotations = pgTable(
style: varchar('style', { length: 20 }).notNull().default('highlight'),
note: text('note'),
chapterTitle: varchar('chapter_title', { length: 500 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index('annotations_user_id_idx').on(t.userId), index('annotations_user_book_idx').on(t.userId, t.bookId)],
(t) => [
index('annotations_user_id_idx').on(t.userId),
index('annotations_user_book_idx').on(t.userId, t.bookId),
check('annotations_style_chk', sql`${t.style} in ('highlight', 'underline', 'strikethrough', 'squiggly')`),
],
);
export type AnnotationRow = typeof annotations.$inferSelect;
@@ -202,12 +240,15 @@ export const readerDefaultPreferences = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
formatGroup: varchar('format_group', { length: 10 }).notNull(),
settings: jsonb('settings').notNull(),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [uniqueIndex('rdp_user_format_idx').on(t.userId, t.formatGroup)],
(t) => [
uniqueIndex('rdp_user_format_idx').on(t.userId, t.formatGroup),
check('reader_default_preferences_format_group_chk', sql`${t.formatGroup} in ('epub', 'pdf', 'cbx', 'audio')`),
],
);
export type ReaderDefaultPreference = typeof readerDefaultPreferences.$inferSelect;
@@ -224,7 +265,7 @@ export const readerPreferences = pgTable(
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
settings: jsonb('settings').notNull(),
updatedAt: timestamp('updated_at')
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
+27 -15
View File
@@ -1,21 +1,33 @@
import { integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { libraries } from './libraries';
export const scanJobs = pgTable('scan_jobs', {
id: serial('id').primaryKey(),
libraryId: integer('library_id')
.notNull()
.references(() => libraries.id, { onDelete: 'cascade' }),
status: varchar('status', { length: 20 }).notNull().default('running'),
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
addedCount: integer('added_count').notNull().default(0),
updatedCount: integer('updated_count').notNull().default(0),
missingCount: integer('missing_count').notNull().default(0),
errorMessage: text('error_message'),
startedAt: timestamp('started_at').defaultNow().notNull(),
completedAt: timestamp('completed_at'),
});
export const scanJobs = pgTable(
'scan_jobs',
{
id: serial('id').primaryKey(),
libraryId: integer('library_id')
.notNull()
.references(() => libraries.id, { onDelete: 'cascade' }),
status: varchar('status', { length: 20 }).notNull().default('running'),
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
addedCount: integer('added_count').notNull().default(0),
updatedCount: integer('updated_count').notNull().default(0),
missingCount: integer('missing_count').notNull().default(0),
errorMessage: text('error_message'),
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(t) => [
index('scan_jobs_library_status_idx').on(t.libraryId, t.status),
check('scan_jobs_status_chk', sql`${t.status} in ('running', 'completed', 'failed')`),
check('scan_jobs_triggered_by_chk', sql`${t.triggeredBy} in ('manual', 'watcher', 'schedule')`),
check('scan_jobs_added_count_nonnegative_chk', sql`${t.addedCount} >= 0`),
check('scan_jobs_updated_count_nonnegative_chk', sql`${t.updatedCount} >= 0`),
check('scan_jobs_missing_count_nonnegative_chk', sql`${t.missingCount} >= 0`),
],
);
export type ScanJob = typeof scanJobs.$inferSelect;
export type NewScanJob = typeof scanJobs.$inferInsert;
+3 -1
View File
@@ -91,6 +91,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
const oidcSessionRepo = {
findActiveByUserId: vi.fn().mockResolvedValue(null),
revokeByUserId: vi.fn().mockResolvedValue(undefined),
touchActiveByUserId: vi.fn().mockResolvedValue(undefined),
};
const oidcDiscovery = {
getDiscoveryDoc: vi.fn(),
@@ -345,7 +346,7 @@ describe('AuthService', () => {
});
it('rotates token and sets cookies when refresh succeeds', async () => {
const { service, db } = makeService();
const { service, db, oidcSessionRepo } = makeService();
const reply = makeReply();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({
id: 11,
@@ -362,6 +363,7 @@ describe('AuthService', () => {
const result = await service.refresh(makeRequest({ refresh_token: 'ok-token' }), reply);
expect(result).toEqual({ accessToken: 'signed-jwt' });
expect(db.update).toHaveBeenCalled();
expect(oidcSessionRepo.touchActiveByUserId).toHaveBeenCalledWith(5, expect.any(Date));
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalled();
});
});
+22 -9
View File
@@ -79,11 +79,15 @@ export class AuthService {
const passwordHash = await hash(dto.password, 12);
return this.db.transaction(async (tx) => {
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
const existingUsername = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.username})`, dto.username.toLowerCase()),
});
if (existingUsername) throw new ConflictException('Username already taken');
if (dto.email) {
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
const existingEmail = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.email})`, dto.email.toLowerCase()),
});
if (existingEmail) throw new ConflictException('Email already in use');
}
@@ -135,12 +139,16 @@ export class AuthService {
throw new ConflictException('Setup already completed');
}
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
const existingUsername = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.username})`, dto.username.toLowerCase()),
});
if (existingUsername) {
throw new ConflictException('Username already taken');
}
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
const existingEmail = await tx.query.users.findFirst({
where: eq(sql`lower(${schema.users.email})`, dto.email.toLowerCase()),
});
if (existingEmail) {
throw new ConflictException('Email already in use');
}
@@ -222,7 +230,8 @@ export class AuthService {
async issueTokensForUser(userId: number, reply: FastifyReply) {
const user = await this.userService.findByIdWithPermissions(userId);
if (!user || !user.active) throw new UnauthorizedException();
const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(userId, user.tokenVersion);
await this.oidcSessionRepo.touchActiveByUserId(userId, refreshExpiresAt);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken, user: this.buildUserResponse(user) };
@@ -278,7 +287,8 @@ export class AuthService {
// Rotate: revoke old, issue new
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
await this.oidcSessionRepo.touchActiveByUserId(row.userId, refreshExpiresAt);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
@@ -491,17 +501,20 @@ export class AuthService {
});
}
getRefreshTokenExpiryDate(baseDate = new Date()) {
return new Date(baseDate.getTime() + parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d'));
}
private async issueTokenPair(userId: number, tokenVersion: number) {
const accessToken = this.jwtService.sign({ sub: userId, ver: tokenVersion });
const rawRefreshToken = randomBytes(32).toString('hex');
const tokenHash = sha256(rawRefreshToken);
const refreshTtlMs = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d');
const expiresAt = new Date(Date.now() + refreshTtlMs);
const expiresAt = this.getRefreshTokenExpiryDate();
await this.db.insert(schema.refreshTokens).values({ userId, tokenHash, expiresAt });
return { accessToken, rawRefreshToken };
return { accessToken, rawRefreshToken, refreshExpiresAt: expiresAt };
}
private assertSetupToken(setupToken: string | undefined) {
@@ -1,10 +1,11 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', parts, values })),
}));
import { and, eq } from 'drizzle-orm';
import { and, eq, gt } from 'drizzle-orm';
import * as schema from '../../../db/schema';
import { OidcSessionRepository } from './oidc-session.repository';
@@ -51,7 +52,7 @@ describe('OidcSessionRepository', () => {
it('creates an OIDC session and returns the inserted row', async () => {
const { db, insert, insertValues, insertReturning } = makeDb();
const repository = new OidcSessionRepository(db as never);
const payload = { userId: 2, oidcSubject: 'sub', oidcIssuer: 'issuer', oidcSessionId: 'sid-1' };
const payload = { userId: 2, oidcSubject: 'sub', oidcIssuer: 'issuer', oidcSessionId: 'sid-1', expiresAt: new Date('2026-01-08T00:00:00Z') };
const created = { id: 7, ...payload };
insertReturning.mockResolvedValue([created]);
@@ -62,7 +63,7 @@ describe('OidcSessionRepository', () => {
expect(insertReturning).toHaveBeenCalledTimes(1);
});
it('finds an active session by sid and always filters out revoked rows', async () => {
it('finds an active session by sid and always filters out revoked and expired rows', async () => {
const { db, findFirst } = makeDb();
const repository = new OidcSessionRepository(db as never);
@@ -70,9 +71,11 @@ describe('OidcSessionRepository', () => {
expect(eq).toHaveBeenCalledWith(schema.oidcSessions.oidcSessionId, 'sid-123');
expect(eq).toHaveBeenCalledWith(schema.oidcSessions.revoked, false);
expect(gt).toHaveBeenCalledWith(schema.oidcSessions.expiresAt, expect.any(Date));
expect(and).toHaveBeenCalledWith(
{ op: 'eq', left: schema.oidcSessions.oidcSessionId, right: 'sid-123' },
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
);
expect(findFirst).toHaveBeenCalledWith({
where: {
@@ -80,6 +83,7 @@ describe('OidcSessionRepository', () => {
clauses: [
{ op: 'eq', left: schema.oidcSessions.oidcSessionId, right: 'sid-123' },
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
],
},
});
@@ -98,6 +102,7 @@ describe('OidcSessionRepository', () => {
{ op: 'eq', left: schema.oidcSessions.oidcSubject, right: 'subject-a' },
{ op: 'eq', left: schema.oidcSessions.oidcIssuer, right: 'https://issuer.example' },
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
],
},
});
@@ -115,6 +120,7 @@ describe('OidcSessionRepository', () => {
clauses: [
{ op: 'eq', left: schema.oidcSessions.userId, right: 42 },
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
],
});
@@ -148,4 +154,22 @@ describe('OidcSessionRepository', () => {
});
expect(updateWhere).toHaveBeenNthCalledWith(3, { op: 'eq', left: schema.oidcSessions.userId, right: 91 });
});
it('extends active sessions for a user without reviving expired ones', async () => {
const { db, updateSet, updateWhere } = makeDb();
const repository = new OidcSessionRepository(db as never);
const expiresAt = new Date('2026-01-09T00:00:00Z');
await repository.touchActiveByUserId(42, expiresAt);
expect(updateSet).toHaveBeenCalledWith({ expiresAt });
expect(updateWhere).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: schema.oidcSessions.userId, right: 42 },
{ op: 'eq', left: schema.oidcSessions.revoked, right: false },
expect.objectContaining({ op: 'gt', left: schema.oidcSessions.expiresAt, right: expect.any(Date) }),
],
});
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { and, eq, gt } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db/db.module';
@@ -18,13 +18,18 @@ export class OidcSessionRepository {
async findActiveBySid(sid: string) {
return this.db.query.oidcSessions.findFirst({
where: and(eq(schema.oidcSessions.oidcSessionId, sid), eq(schema.oidcSessions.revoked, false)),
where: and(eq(schema.oidcSessions.oidcSessionId, sid), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())),
});
}
async findActiveBySubjectAndIssuer(subject: string, issuer: string) {
return this.db.query.oidcSessions.findMany({
where: and(eq(schema.oidcSessions.oidcSubject, subject), eq(schema.oidcSessions.oidcIssuer, issuer), eq(schema.oidcSessions.revoked, false)),
where: and(
eq(schema.oidcSessions.oidcSubject, subject),
eq(schema.oidcSessions.oidcIssuer, issuer),
eq(schema.oidcSessions.revoked, false),
gt(schema.oidcSessions.expiresAt, new Date()),
),
});
}
@@ -41,11 +46,18 @@ export class OidcSessionRepository {
async findActiveByUserId(userId: number) {
return this.db.query.oidcSessions.findFirst({
where: and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false)),
where: and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())),
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
}
async touchActiveByUserId(userId: number, expiresAt: Date) {
await this.db
.update(schema.oidcSessions)
.set({ expiresAt })
.where(and(eq(schema.oidcSessions.userId, userId), eq(schema.oidcSessions.revoked, false), gt(schema.oidcSessions.expiresAt, new Date())));
}
async revokeByUserId(userId: number) {
await this.db.update(schema.oidcSessions).set({ revoked: true }).where(eq(schema.oidcSessions.userId, userId));
}
@@ -52,6 +52,7 @@ function makeService() {
setPermissionsDirectly: vi.fn(),
};
const authService = {
getRefreshTokenExpiryDate: vi.fn().mockReturnValue(new Date('2026-01-08T00:00:00Z')),
issueTokensForUser: vi.fn(),
};
@@ -69,7 +70,7 @@ function makeService() {
authService as never,
);
return { service, claimExtractor };
return { service, claimExtractor, userService, appSettings, authService, sessionRepo };
}
describe('OidcService', () => {
@@ -96,4 +97,50 @@ describe('OidcService', () => {
),
).rejects.toThrow(UnauthorizedException);
});
it('reuses an OIDC user when auto-provision races on the new unique constraint', async () => {
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
const existingUser = { id: 7, active: true };
appSettings.getOidcConfig.mockResolvedValue({
enabled: true,
issuerUri: 'https://issuer.example',
clientId: 'client-id',
clientSecret: 'client-secret',
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: ['library_download'] },
});
claimExtractor.extract.mockReturnValue({
subject: 'sub-1',
username: 'u1',
name: 'User One',
email: 'u1@example.com',
groups: [],
});
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token' });
await expect(
service.handleCallback(
{
code: 'code',
codeVerifier: 'verifier',
redirectUri: 'https://app.example/callback',
nonce: 'nonce',
state: 'state',
},
{ setCookie: vi.fn() } as never,
),
).resolves.toEqual({ accessToken: 'token' });
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
expect(sessionRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
oidcSubject: 'sub-1',
oidcIssuer: 'https://issuer.example',
expiresAt: new Date('2026-01-08T00:00:00Z'),
}),
);
});
});
+43 -10
View File
@@ -14,6 +14,21 @@ import { OidcStateService } from './oidc-state.service';
import { OidcTokenClientService } from './oidc-token-client.service';
import { OidcTokenValidatorService } from './oidc-token-validator.service';
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const directCode = (error as { code?: unknown }).code;
if (directCode === '23505') return true;
if (!(error instanceof Error)) return false;
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
return causeCode === '23505';
}
function toThrowable(error: unknown, fallbackMessage: string): Error {
return error instanceof Error ? error : new UnauthorizedException(fallbackMessage);
}
@Injectable()
export class OidcService {
private readonly logger = new Logger(OidcService.name);
@@ -87,6 +102,7 @@ export class OidcService {
oidcIssuer: disc.issuer,
oidcSessionId: sid,
idTokenHint: tokens.idToken,
expiresAt: this.authService.getRefreshTokenExpiryDate(),
});
return this.authService.issueTokensForUser(user.id, reply);
@@ -104,23 +120,40 @@ export class OidcService {
if (!user && config.autoProvision.allowLocalLinking) {
const byUsername = await this.userService.findByUsername(claims.username);
if (byUsername) {
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
let linkConflict: unknown;
try {
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
} catch (error) {
if (!isUniqueViolation(error)) throw error;
linkConflict = error;
}
user = await this.userService.findByOidcSubject(claims.subject, issuer);
if (!user && linkConflict) {
throw toThrowable(linkConflict, 'OIDC identity conflict');
}
}
}
// 3. Auto-provision new user
let createdUser = false;
if (!user && config.autoProvision.enabled) {
user = await this.userService.createOidcUser({
username: claims.username,
name: claims.name,
email: claims.email,
oidcSubject: claims.subject,
oidcIssuer: issuer,
avatarUrl: claims.avatarUrl,
});
try {
user = await this.userService.createOidcUser({
username: claims.username,
name: claims.name,
email: claims.email,
oidcSubject: claims.subject,
oidcIssuer: issuer,
avatarUrl: claims.avatarUrl,
});
createdUser = true;
} catch (error) {
if (!isUniqueViolation(error)) throw error;
user = await this.userService.findByOidcSubject(claims.subject, issuer);
if (!user) throw toThrowable(error, 'OIDC user provisioning conflict');
}
if (config.autoProvision.defaultPermissionNames?.length) {
if (createdUser && config.autoProvision.defaultPermissionNames?.length) {
await this.userService.setPermissionsDirectly(user.id, config.autoProvision.defaultPermissionNames as Permission[]);
}
}
@@ -89,6 +89,7 @@ describe('Book DTO validation', () => {
expect((await errorsFor(UpdateBookMetadataDto, { publishedYear: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateBookMetadataDto, { authors: ['ok', 1] })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateBookMetadataDto, { language: 'english-too-long' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateBookMetadataDto, { isbn10: '12345678901' })).length).toBeGreaterThan(0);
});
it('rejects legacy top-level audiobook fields and accepts nested audioMetadata', async () => {
@@ -38,7 +38,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsInt() @Min(1) pageCount?: number | null;
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
@IsOptional() @IsNumber() seriesIndex?: number | null;
@IsOptional() @IsString() @MaxLength(13) isbn10?: string | null;
@IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
@@ -25,9 +25,11 @@ function makeDb() {
orderByResult.limit.mockResolvedValue([]);
const insertResult = { values: vi.fn() };
const valuesResult = { returning: vi.fn() };
const valuesResult = { onConflictDoNothing: vi.fn() };
const conflictResult = { returning: vi.fn() };
insertResult.values.mockReturnValue(valuesResult);
valuesResult.returning.mockResolvedValue([]);
valuesResult.onConflictDoNothing.mockReturnValue(conflictResult);
conflictResult.returning.mockResolvedValue([]);
const deleteResult = { where: vi.fn() };
const deleteWhereResult = { returning: vi.fn() };
@@ -42,6 +44,7 @@ function makeDb() {
_orderBy: orderByResult,
_insert: insertResult,
_values: valuesResult,
_conflict: conflictResult,
_deleteWhere: deleteWhereResult,
};
return db;
@@ -116,13 +119,23 @@ describe('BookmarkRepository', () => {
it('inserts and returns created row', async () => {
const { repo, db } = makeRepository();
const row = makeRow({ id: 8, cfi: null, positionSeconds: 15, title: '00:00:15' });
db._values.returning.mockResolvedValue([row]);
db._conflict.returning.mockResolvedValue([row]);
const result = await repo.create(10, 5, { cfi: null, title: '00:00:15', positionSeconds: 15 });
expect(db._insert.values).toHaveBeenCalledWith({ userId: 10, bookId: 5, cfi: null, title: '00:00:15', positionSeconds: 15 });
expect(db._values.onConflictDoNothing).toHaveBeenCalledTimes(1);
expect(result).toEqual(row);
});
it('returns null when a duplicate insert is ignored by the database', async () => {
const { repo, db } = makeRepository();
db._conflict.returning.mockResolvedValue([]);
const result = await repo.create(10, 5, { cfi: 'epubcfi(/6/2)', title: 'Chapter 1', positionSeconds: null });
expect(result).toBeNull();
});
});
describe('delete', () => {
@@ -50,8 +50,9 @@ export class BookmarkRepository {
const [row] = await this.db
.insert(bookmarks)
.values({ userId, bookId, cfi: data.cfi ?? null, title: data.title, positionSeconds: data.positionSeconds ?? null })
.onConflictDoNothing()
.returning();
return row;
return row ?? null;
}
async delete(bookId: number, bookmarkId: number, userId: number) {
@@ -122,6 +122,18 @@ describe('BookmarkService', () => {
expect(result.positionSeconds).toBe(42);
});
it('re-reads and returns the existing bookmark when a concurrent duplicate insert is ignored', async () => {
const { service, bookmarkRepo } = makeService();
const existing = makeBookmarkRow({ id: 13, cfi: null, positionSeconds: 42, title: '00:00:42' });
bookmarkRepo.findExistingByLocation.mockResolvedValueOnce(null).mockResolvedValueOnce(existing);
bookmarkRepo.create.mockResolvedValue(null);
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
expect(bookmarkRepo.findExistingByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: null, positionSeconds: 42 });
expect(result.id).toBe(13);
});
it('propagates access errors and does not query repository', async () => {
const { service, bookService, bookmarkRepo } = makeService();
bookService.verifyBookAccess.mockRejectedValue(new NotFoundException('Book 5 not found'));
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import type { NewBookmark } from '../../db/schema';
@@ -7,6 +7,8 @@ import { BookmarkRepository } from './bookmark.repository';
import { BookmarkResponseDto } from './dto/bookmark-response.dto';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
const BOOKMARK_CONFLICT_MESSAGE = 'Bookmark already exists';
@Injectable()
export class BookmarkService {
constructor(
@@ -30,6 +32,14 @@ export class BookmarkService {
if (existing) return BookmarkResponseDto.from(existing);
const row = await this.bookmarkRepo.create(user.id, bookId, createData);
if (!row) {
const concurrent = await this.bookmarkRepo.findExistingByLocation(user.id, bookId, {
cfi: createData.cfi,
positionSeconds: createData.positionSeconds,
});
if (concurrent) return BookmarkResponseDto.from(concurrent);
throw new ConflictException(BOOKMARK_CONFLICT_MESSAGE);
}
return BookmarkResponseDto.from(row);
}
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { BooksPage } from '@projectx/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
@@ -15,6 +15,17 @@ const COLLECTION_NOT_FOUND_MESSAGE = 'Collection not found';
const COLLECTION_ACCESS_DENIED_MESSAGE = 'No access to this collection';
const BOOKS_NOT_FOUND_MESSAGE = 'One or more books were not found';
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const directCode = (error as { code?: unknown }).code;
if (directCode === '23505') return true;
if (!(error instanceof Error)) return false;
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
return causeCode === '23505';
}
@Injectable()
export class CollectionService {
private readonly logger = new Logger(CollectionService.name);
@@ -71,26 +82,40 @@ export class CollectionService {
}
async create(dto: CreateCollectionDto, user: RequestUser) {
const [inserted] = await this.collectionRepo.insert({
userId: user.id,
name: dto.name,
icon: dto.icon.trim(),
description: dto.description ?? null,
syncToKobo: dto.syncToKobo ?? false,
});
const [collection] = await this.collectionRepo.findById(inserted.id);
return collection;
try {
const [inserted] = await this.collectionRepo.insert({
userId: user.id,
name: dto.name,
icon: dto.icon.trim(),
description: dto.description ?? null,
syncToKobo: dto.syncToKobo ?? false,
});
const [collection] = await this.collectionRepo.findById(inserted.id);
return collection;
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException('A collection with this name already exists');
}
throw error;
}
}
async update(id: number, dto: UpdateCollectionDto, user: RequestUser) {
const existing = await this.findCollectionForUserOrThrow(id, user);
await this.collectionRepo.update(id, existing.userId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.icon !== undefined && { icon: dto.icon.trim() || null }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.syncToKobo !== undefined && { syncToKobo: dto.syncToKobo }),
});
try {
await this.collectionRepo.update(id, existing.userId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.icon !== undefined && { icon: dto.icon.trim() || null }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.syncToKobo !== undefined && { syncToKobo: dto.syncToKobo }),
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException('A collection with this name already exists');
}
throw error;
}
const [updated] = await this.collectionRepo.findById(id);
return updated;
@@ -144,9 +144,9 @@ describe('EmailRecipientGroupRepository', () => {
const { db, insertBuilder, deleteBuilder } = makeDb();
const repo = new EmailRecipientGroupRepository(db as never);
void repo.addMember(3, 4);
void repo.addMember(3, 4, 8);
expect(db.insert).toHaveBeenCalledWith(emailRecipientGroupMembers);
expect(insertBuilder.values).toHaveBeenCalledWith({ groupId: 3, recipientId: 4 });
expect(insertBuilder.values).toHaveBeenCalledWith({ groupId: 3, recipientId: 4, userId: 8 });
expect(insertBuilder.onConflictDoNothing).toHaveBeenCalled();
expect(insertBuilder.returning).toHaveBeenCalled();
@@ -59,8 +59,8 @@ export class EmailRecipientGroupRepository {
.returning();
}
addMember(groupId: number, recipientId: number) {
return this.db.insert(emailRecipientGroupMembers).values({ groupId, recipientId }).onConflictDoNothing().returning();
addMember(groupId: number, recipientId: number, userId: number) {
return this.db.insert(emailRecipientGroupMembers).values({ groupId, recipientId, userId }).onConflictDoNothing().returning();
}
removeMember(groupId: number, recipientId: number) {
@@ -135,7 +135,7 @@ describe('EmailRecipientGroupService', () => {
describe('addMember', () => {
it('should add member and return updated group', async () => {
await service.addMember(10, 100, mockUser);
expect(repo.addMember).toHaveBeenCalledWith(10, 100);
expect(repo.addMember).toHaveBeenCalledWith(10, 100, 1);
expect(repo.findMembers).toHaveBeenCalledWith(10);
});
@@ -86,7 +86,7 @@ export class EmailRecipientGroupService {
const [recipient] = await this.recipientRepo.findById(recipientId);
if (!recipient) throw new NotFoundException('Recipient not found');
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot add a recipient you do not own');
await this.repo.addMember(groupId, recipientId);
await this.repo.addMember(groupId, recipientId, user.id);
return this.findOne(groupId, user);
}
@@ -241,7 +241,7 @@ export class MigrationImportRepository {
}
async insertBookmark(values: typeof schema.bookmarks.$inferInsert): Promise<void> {
await this.db.insert(schema.bookmarks).values(values);
await this.db.insert(schema.bookmarks).values(values).onConflictDoNothing();
}
// --- Annotations ---
@@ -472,7 +472,7 @@ export class MigrationImportRepository {
async batchInsertBookmarks(items: Array<typeof schema.bookmarks.$inferInsert>): Promise<void> {
if (items.length === 0) return;
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
await this.db.insert(schema.bookmarks).values(batch);
await this.db.insert(schema.bookmarks).values(batch).onConflictDoNothing();
}
}
@@ -3,18 +3,26 @@ import { Logger } from '@nestjs/common';
import * as schema from '../../db/schema';
import { CleanupService } from './cleanup.service';
function makeDb(options?: { refreshDeleteResult?: { rowCount?: number }; resetDeleteResult?: { rowCount?: number } }) {
function makeDb(options?: {
refreshDeleteResult?: { rowCount?: number };
resetDeleteResult?: { rowCount?: number };
oidcDeleteResult?: { rowCount?: number };
}) {
const refreshDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.refreshDeleteResult ?? { rowCount: 2 }),
};
const resetDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.resetDeleteResult ?? { rowCount: 1 }),
};
const oidcDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.oidcDeleteResult ?? { rowCount: 3 }),
};
return {
delete: vi.fn().mockReturnValueOnce(refreshDeleteBuilder).mockReturnValueOnce(resetDeleteBuilder),
delete: vi.fn().mockReturnValueOnce(refreshDeleteBuilder).mockReturnValueOnce(resetDeleteBuilder).mockReturnValueOnce(oidcDeleteBuilder),
__refreshDeleteBuilder: refreshDeleteBuilder,
__resetDeleteBuilder: resetDeleteBuilder,
__oidcDeleteBuilder: oidcDeleteBuilder,
};
}
@@ -33,7 +41,7 @@ describe('CleanupService', () => {
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
it('deletes expired or invalid tokens from both tables and logs completion', async () => {
it('deletes expired or invalid auth state from all three tables and logs completion', async () => {
const db = makeDb();
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const service = new CleanupService(db as never);
@@ -42,10 +50,12 @@ describe('CleanupService', () => {
expect(db.delete).toHaveBeenNthCalledWith(1, schema.refreshTokens);
expect(db.delete).toHaveBeenNthCalledWith(2, schema.passwordResetTokens);
expect(db.delete).toHaveBeenNthCalledWith(3, schema.oidcSessions);
expect(db.__refreshDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(db.__resetDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [start]'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [end] refreshDeleted=2 resetDeleted=1'));
expect(db.__oidcDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [start]'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [end] refreshDeleted=2 resetDeleted=1 oidcDeleted=3'));
});
it('logs failure and rethrows on cleanup errors', async () => {
@@ -56,6 +66,6 @@ describe('CleanupService', () => {
const service = new CleanupService(db as never);
await expect(service.cleanup()).rejects.toThrow('delete failed');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_tokens] [fail]'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [fail]'));
});
});
+10 -5
View File
@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { isNotNull, lt, or } from 'drizzle-orm';
import { eq, isNotNull, lt, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -20,8 +20,8 @@ export class CleanupService implements OnApplicationBootstrap {
@Cron('0 3 * * *')
async cleanup() {
const startedAt = Date.now();
const event = 'seed.cleanup_tokens';
this.logger.log(`[${event}] [start] - token cleanup started`);
const event = 'seed.cleanup_auth_state';
this.logger.log(`[${event}] [start] - auth state cleanup started`);
try {
const now = new Date();
@@ -32,13 +32,18 @@ export class CleanupService implements OnApplicationBootstrap {
const { rowCount: resetCount } = await this.db
.delete(schema.passwordResetTokens)
.where(or(lt(schema.passwordResetTokens.expiresAt, now), isNotNull(schema.passwordResetTokens.usedAt)));
const { rowCount: oidcCount } = await this.db
.delete(schema.oidcSessions)
.where(or(lt(schema.oidcSessions.expiresAt, now), eq(schema.oidcSessions.revoked, true)));
this.logger.log(
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} durationMs=${Date.now() - startedAt} - token cleanup completed`,
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} oidcDeleted=${oidcCount ?? 0} durationMs=${Date.now() - startedAt} - auth state cleanup completed`,
);
} catch (error) {
const { errorClass, errorMessage } = getSanitizedErrorInfo(error);
this.logger.error(
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - token cleanup failed`,
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - auth state cleanup failed`,
);
throw error;
}