import type { Kysely } from "kysely"; import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts"; // 来源域:site、site_account、feed_source、source_release、observation(REQ §4.1–§4.6) // 站点级限流共享;多 Feed 观察关联同一 source_release;含 passkey 的 URL 只以 secret_ref 保存。 // biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 export async function up(db: Kysely): Promise { await execDdl( db, ` ${entityTable( "site", ` key text not null check (key ~ '^[a-z][a-z0-9_-]*$'), name text not null, adapter text not null ${oneOf("adapter", ["nexusphp", "mteam_api", "generic_rss"])}, base_url text not null, enabled boolean not null default true, rate_limit jsonb not null default '{}'::jsonb, config jsonb not null default '{}'::jsonb, health_status text not null default 'unknown' ${oneOf("health_status", ["unknown", "healthy", "degraded", "failing", "disabled"])}, circuit_state text not null default 'closed' ${oneOf("circuit_state", ["closed", "open", "half_open"])}, circuit_open_until timestamptz, snapshot_enabled boolean not null default false, snapshot_interval_sec integer, last_snapshot_at timestamptz, snapshot_cursor jsonb not null default '{}'::jsonb, constraint site_key_key unique (key)`, )} ${entityTable( "site_account", ` site_id uuid not null references site (id) on delete cascade, label text not null, auth_kind text not null ${oneOf("auth_kind", ["cookie", "passkey", "api_key", "password"])}, secret_ref uuid references secret (id) on delete restrict, enabled boolean not null default true, status text not null default 'unknown' ${oneOf("status", ["unknown", "active", "invalid", "expired"])}, last_verified_at timestamptz, user_info jsonb not null default '{}'::jsonb, constraint site_account_label_key unique (site_id, label)`, )} ${entityTable( "feed_source", ` site_id uuid references site (id) on delete cascade, site_account_id uuid references site_account (id) on delete set null, name text not null, purpose text not null default 'generic' ${oneOf("purpose", ["movie", "series", "variety", "promotion", "search", "generic"])}, kind text not null default 'rss' ${oneOf("kind", ["rss", "atom", "list", "query"])}, url_secret_ref uuid references secret (id) on delete restrict, url_display text not null, parser_version text not null default '1', enabled boolean not null default true, poll_interval_sec integer not null default 900 check (poll_interval_sec >= 60), filter jsonb not null default '{}'::jsonb, cursor jsonb not null default '{}'::jsonb, etag text, last_modified text, last_fetch_at timestamptz, last_success_at timestamptz, next_fetch_at timestamptz, consecutive_failures integer not null default 0, backoff_until timestamptz, health_status text not null default 'unknown' ${oneOf("health_status", ["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"])}, last_error jsonb`, )} create index feed_source_due_idx on feed_source (next_fetch_at) where enabled; create index feed_source_site_idx on feed_source (site_id); ${entityTable( "source_release", ` site_id uuid not null references site (id) on delete restrict, site_release_id text not null, title text not null, subtitle text, category_raw text, tags_raw text[] not null default '{}'::text[], size_bytes bigint, infohash_v1 text, infohash_v2 text, file_list jsonb, promotion jsonb not null default '{}'::jsonb, promotion_expires_at timestamptz, seeders integer, leechers integer, source_published_at timestamptz, first_seen_at timestamptz not null default now(), last_seen_at timestamptz not null default now(), detail_fetched_at timestamptz, parsed jsonb not null default '{}'::jsonb, parser_version text, media_entity_id uuid references media_entity (id) on delete set null, content_unit_id uuid references content_unit (id) on delete set null, resolution_status text not null default 'unresolved' ${oneOf("resolution_status", ["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"])}, resolution_evidence jsonb not null default '{}'::jsonb, availability text not null default 'active' ${oneOf("availability", ["active", "site_disabled", "expired", "unknown"])}, raw jsonb not null default '{}'::jsonb, constraint source_release_site_release_key unique (site_id, site_release_id)`, )} create index source_release_infohash_v1_idx on source_release (infohash_v1) where infohash_v1 is not null; create index source_release_infohash_v2_idx on source_release (infohash_v2) where infohash_v2 is not null; create index source_release_entity_idx on source_release (media_entity_id) where media_entity_id is not null; create index source_release_unresolved_idx on source_release (first_seen_at) where resolution_status in ('unresolved', 'ambiguous'); create index source_release_title_trgm_idx on source_release using gin (title gin_trgm_ops); ${entityTable( "observation", ` feed_id uuid not null references feed_source (id) on delete cascade, guid text not null, content_digest text not null, source_release_id uuid references source_release (id) on delete set null, title text not null, link_redacted text, raw jsonb not null default '{}'::jsonb, source_published_at timestamptz, fetched_at timestamptz not null default now(), first_seen_at timestamptz not null default now(), last_seen_at timestamptz not null default now(), status text not null default 'new' ${oneOf("status", ["new", "linked", "ignored", "error"])}, resolve_attempts integer not null default 0, next_resolve_at timestamptz, last_error jsonb, constraint observation_feed_guid_key unique (feed_id, guid)`, )} create index observation_release_idx on observation (source_release_id) where source_release_id is not null; create index observation_next_resolve_idx on observation (next_resolve_at) where status in ('new', 'error'); `, ); } // biome-ignore lint/suspicious/noExplicitAny: 同上 export async function down(db: Kysely): Promise { await execDdl(db, dropTables(["observation", "source_release", "feed_source", "site_account", "site"])); }