Files
UHD_Media_Tool/packages/db/migrations/0006_sources.ts
T
rootandClaude Fable 5.1 cc8b3a7df4 feat(db,contracts): full v0.1 data model migrations and shared zod contracts (W1-1)
- migrations 0002-0012 split by domain (foundation/instances/content/identity/
  sources/assets/policy/execution/agent/ops/organization): 41 tables, uuid pk,
  created_at/updated_at + touch trigger, text+CHECK status sets, pgcrypto/pg_trgm
- key constraints: source_release(site_id, site_release_id), observation(feed_id, guid),
  external_identity accepted partial unique, download_task(instance_id, torrent_hash),
  one active acquisition_plan per slot, one active path_binding per entity/season,
  asset_location root+path uniqueness, organization_record change_seq/change_xid cursor
- secret table (ciphertext/nonce/kdf) + secret_ref columns; outbox dispatch fields (ADR-002)
- src/types.ts generated from the migrated schema (scripts/gen-types.ts), drift-checked in tests;
  int8 -> number, date -> string parsers; migrator accepts migrationTableSchema
- contracts: Row/Create/Update schemas per domain, public organization-record API
  (changeSeq cursor), GlobalSettings, CapabilityDescriptor + 14 capability IO schemas,
  typed SSE events, Api endpoint table
- tests: migration up/down round trip on real PG in an isolated schema, constraint checks,
  contracts<->db column/enum sync, positive/negative parse for every exported schema

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:11:37 +08:00

141 lines
6.1 KiB
TypeScript

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<any>): Promise<void> {
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<any>): Promise<void> {
await execDdl(db, dropTables(["observation", "source_release", "feed_source", "site_account", "site"]));
}