- 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]>
141 lines
6.1 KiB
TypeScript
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"]));
|
|
}
|