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]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:37 +08:00
1 parent 7ba712ea55
commit cc8b3a7df4
42 files changed
+7026 -25

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 底座:扩展、updated_at 触发器函数、加密凭证表 secret、outbox 投递字段(ADR-002 §2.1)
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create extension if not exists pgcrypto with schema public;
create extension if not exists pg_trgm with schema public;
create or replace function umt_touch_updated_at() returns trigger language plpgsql as $$
begin
new.updated_at := now();
return new;
end $$;
${entityTable(
"secret",
`
name text not null,
purpose text not null ${oneOf("purpose", ["site_cookie", "site_passkey", "site_api_key", "site_password", "feed_url", "downloader_password", "media_server_api_key", "metadata_api_key", "llm_api_key", "other"])},
ciphertext bytea not null,
nonce bytea not null,
auth_tag bytea,
algorithm text not null default 'aes-256-gcm',
kdf text not null default 'hkdf-sha256',
kdf_params jsonb not null default '{}'::jsonb,
key_version integer not null default 1,
rotated_at timestamptz,
constraint secret_name_key unique (name)`,
)}
alter table outbox_event
add column idempotency_key text,
add column status text not null default 'pending' ${oneOf("status", ["pending", "dispatched", "failed", "dead"])},
add column attempts integer not null default 0,
add column next_attempt_at timestamptz not null default now(),
add column last_error jsonb;
create unique index outbox_event_idempotency_key_idx on outbox_event (idempotency_key) where idempotency_key is not null;
create index outbox_event_dispatch_idx on outbox_event (next_attempt_at) where status = 'pending';
alter table api_key add column updated_at timestamptz not null default now();
create trigger api_key_touch_updated_at before update on api_key
for each row execute function umt_touch_updated_at();
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
drop trigger if exists api_key_touch_updated_at on api_key;
alter table api_key drop column if exists updated_at;
drop index if exists outbox_event_dispatch_idx;
drop index if exists outbox_event_idempotency_key_idx;
alter table outbox_event
drop column if exists idempotency_key,
drop column if exists status,
drop column if exists attempts,
drop column if exists next_attempt_at,
drop column if exists last_error;
${dropTables(["secret"])}
drop function if exists umt_touch_updated_at();
-- 扩展为库级共享对象,down 不卸载
`,
);
}
+94
View File
@@ -0,0 +1,94 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 实例域:媒体库根、下载器实例、媒体服务器实例、多实例路径映射(REQ §7.1、§8.4、§12.5)
const HEALTH = ["unknown", "healthy", "degraded", "offline"] as const;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"library_root",
`
name text not null,
media_kind text not null default 'mixed' ${oneOf("media_kind", ["movie", "series", "variety", "mixed", "other"])},
local_path text not null,
management_mode text not null default 'register_only' ${oneOf("management_mode", ["register_only", "managed"])},
enabled boolean not null default true,
sentinel_file text,
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
last_health_at timestamptz,
health_detail jsonb not null default '{}'::jsonb,
notes text,
constraint library_root_name_key unique (name),
constraint library_root_local_path_key unique (local_path)`,
)}
${entityTable(
"downloader_instance",
`
name text not null,
kind text not null default 'qbittorrent' ${oneOf("kind", ["qbittorrent"])},
base_url text not null,
username text,
secret_ref uuid references secret (id) on delete restrict,
managed_tag text not null default 'umt',
cache_volume_key text,
enabled boolean not null default true,
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
last_seen_at timestamptz,
version_info jsonb not null default '{}'::jsonb,
settings jsonb not null default '{}'::jsonb,
constraint downloader_instance_name_key unique (name)`,
)}
${entityTable(
"media_server_instance",
`
name text not null,
kind text not null default 'emby' ${oneOf("kind", ["emby", "jellyfin"])},
base_url text not null,
user_id text,
secret_ref uuid references secret (id) on delete restrict,
enabled boolean not null default true,
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
last_seen_at timestamptz,
version_info jsonb not null default '{}'::jsonb,
settings jsonb not null default '{}'::jsonb,
constraint media_server_instance_name_key unique (name)`,
)}
${entityTable(
"path_mapping",
`
view_kind text not null ${oneOf("view_kind", ["downloader", "media_server"])},
downloader_instance_id uuid references downloader_instance (id) on delete cascade,
media_server_instance_id uuid references media_server_instance (id) on delete cascade,
library_root_id uuid references library_root (id) on delete set null,
remote_prefix text not null,
local_prefix text not null,
priority integer not null default 0,
enabled boolean not null default true,
verify_status text not null default 'unverified' ${oneOf("verify_status", ["unverified", "ok", "failed"])},
verified_at timestamptz,
verify_detail jsonb not null default '{}'::jsonb,
constraint path_mapping_view_owner_check check (
(view_kind = 'downloader' and downloader_instance_id is not null and media_server_instance_id is null)
or (view_kind = 'media_server' and media_server_instance_id is not null and downloader_instance_id is null)
)`,
)}
create unique index path_mapping_downloader_prefix_idx on path_mapping (downloader_instance_id, remote_prefix)
where downloader_instance_id is not null;
create unique index path_mapping_media_server_prefix_idx on path_mapping (media_server_instance_id, remote_prefix)
where media_server_instance_id is not null;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["path_mapping", "media_server_instance", "downloader_instance", "library_root"]));
}
+73
View File
@@ -0,0 +1,73 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 内容域:本地作品 media_entity、内容单元 content_unit、别名 title_alias(REQ §3.1、§5.1、§5.4、§7.5)
// 本地 UUID 为主身份;外部 ID 与集号都不是唯一身份,编号可修订。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"media_entity",
`
kind text not null ${oneOf("kind", ["movie", "series", "variety", "collection", "other"])},
title text not null,
original_title text,
year integer,
status text not null default 'active' ${oneOf("status", ["provisional", "active", "merged", "archived"])},
merged_into_id uuid references media_entity (id) on delete restrict,
metadata jsonb not null default '{}'::jsonb,
field_locks text[] not null default '{}'::text[],
last_metadata_refresh_at timestamptz,
constraint media_entity_merged_check check ((status = 'merged') = (merged_into_id is not null))`,
)}
create index media_entity_kind_idx on media_entity (kind);
create index media_entity_merged_into_idx on media_entity (merged_into_id) where merged_into_id is not null;
${entityTable(
"content_unit",
`
media_entity_id uuid not null references media_entity (id) on delete restrict,
parent_unit_id uuid references content_unit (id) on delete restrict,
kind text not null ${oneOf("kind", ["season", "episode", "special", "part"])},
content_type text not null default 'main' ${oneOf("content_type", ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction", "unknown"])},
segment text not null default 'full' ${oneOf("segment", ["full", "upper", "middle", "lower", "unknown"])},
season_number integer,
episode_number integer,
source_issue text,
air_date date,
display_order double precision,
title text,
status text not null default 'provisional' ${oneOf("status", ["provisional", "confirmed", "retired"])},
numbering_locked boolean not null default false,
metadata jsonb not null default '{}'::jsonb,
field_locks text[] not null default '{}'::text[]`,
)}
create index content_unit_entity_numbering_idx on content_unit (media_entity_id, season_number, episode_number);
create index content_unit_parent_idx on content_unit (parent_unit_id) where parent_unit_id is not null;
create index content_unit_air_date_idx on content_unit (media_entity_id, air_date) where air_date is not null;
${entityTable(
"title_alias",
`
media_entity_id uuid not null references media_entity (id) on delete cascade,
content_unit_id uuid references content_unit (id) on delete cascade,
alias text not null,
normalized text not null,
language text,
kind text not null default 'other' ${oneOf("kind", ["original", "translated", "short", "site_title", "romanized", "other"])},
source text not null ${oneOf("source", ["tmdb", "user", "site", "agent", "nfo", "import"])},
locked boolean not null default false`,
)}
create unique index title_alias_entity_normalized_idx on title_alias (media_entity_id, content_unit_id, normalized, kind) nulls not distinct;
create index title_alias_normalized_trgm_idx on title_alias using gin (normalized gin_trgm_ops);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["title_alias", "content_unit", "media_entity"]));
}
+66
View File
@@ -0,0 +1,66 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 身份域:external_identity + identity_revision(REQ §7.2)
// provider + entity_type + external_id 对 accepted 唯一;候选可并存但不能同时被当作已确认身份。
const IDENTITY_STATUS = ["accepted", "proposed", "conflicted", "retired"] as const;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"external_identity",
`
media_entity_id uuid references media_entity (id) on delete restrict,
content_unit_id uuid references content_unit (id) on delete restrict,
provider text not null check (provider ~ '^[a-z][a-z0-9_]*$'),
entity_type text not null ${oneOf("entity_type", ["movie", "tv", "season", "episode", "collection", "person"])},
external_id text not null,
status text not null default 'proposed' ${oneOf("status", IDENTITY_STATUS)},
evidence_level text not null default 'needs_review' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])},
evidence jsonb not null default '{}'::jsonb,
locked boolean not null default false,
revision integer not null default 1,
valid_from timestamptz,
valid_until timestamptz,
verified_at timestamptz,
verify_status text not null default 'unverified' ${oneOf("verify_status", ["unverified", "ok", "not_found", "type_mismatch", "error"])},
extra jsonb not null default '{}'::jsonb,
constraint external_identity_target_check check (num_nonnulls(media_entity_id, content_unit_id) = 1)`,
)}
create unique index external_identity_accepted_key on external_identity (provider, entity_type, external_id)
where status = 'accepted';
create unique index external_identity_entity_accepted_key on external_identity (media_entity_id, provider, entity_type)
where status = 'accepted' and media_entity_id is not null;
create unique index external_identity_unit_accepted_key on external_identity (content_unit_id, provider, entity_type)
where status = 'accepted' and content_unit_id is not null;
create index external_identity_lookup_idx on external_identity (provider, entity_type, external_id);
create index external_identity_entity_idx on external_identity (media_entity_id) where media_entity_id is not null;
create index external_identity_unit_idx on external_identity (content_unit_id) where content_unit_id is not null;
${entityTable(
"identity_revision",
`
external_identity_id uuid not null references external_identity (id) on delete cascade,
revision integer not null,
action text not null ${oneOf("action", ["created", "status_changed", "relinked", "evidence_updated", "locked", "unlocked"])},
from_status text ${oneOf("from_status", IDENTITY_STATUS)},
to_status text ${oneOf("to_status", IDENTITY_STATUS)},
before jsonb,
after jsonb not null default '{}'::jsonb,
actor_type text not null ${oneOf("actor_type", ["user", "agent", "system", "api_key"])},
actor_id text,
reason text,
constraint identity_revision_seq_key unique (external_identity_id, revision)`,
)}
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["identity_revision", "external_identity"]));
}
+140
View File
@@ -0,0 +1,140 @@
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"]));
}
+99
View File
@@ -0,0 +1,99 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 文件域:media_asset、asset_unit_coverage、asset_location、path_binding(REQ §7.1、§8.2、§12.2)
// 资产与位置分离;同一库根 + 有效相对路径不能被两个位置同时占用;
// path_binding 一旦建立不因元数据刷新改变,只有显式整理操作可替换(旧绑定标 superseded)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"media_asset",
`
media_entity_id uuid references media_entity (id) on delete restrict,
kind text not null ${oneOf("kind", ["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"])},
parent_asset_id uuid references media_asset (id) on delete restrict,
size_bytes bigint,
content_hash text,
hash_algorithm text,
sample_digest text,
media_info jsonb not null default '{}'::jsonb,
delivery jsonb not null default '{}'::jsonb,
content_edition text,
source_release_id uuid references source_release (id) on delete set null,
status text not null default 'active' ${oneOf("status", ["active", "replaced", "retired", "missing"])},
verified_at timestamptz`,
)}
create index media_asset_entity_idx on media_asset (media_entity_id) where media_entity_id is not null;
create index media_asset_parent_idx on media_asset (parent_asset_id) where parent_asset_id is not null;
${entityTable(
"asset_unit_coverage",
`
media_asset_id uuid not null references media_asset (id) on delete cascade,
content_unit_id uuid not null references content_unit (id) on delete restrict,
coverage text not null default 'full' ${oneOf("coverage", ["full", "partial", "segment", "unknown"])},
segment text ${oneOf("segment", ["full", "upper", "middle", "lower", "unknown"])},
evidence jsonb not null default '{}'::jsonb,
constraint asset_unit_coverage_key unique (media_asset_id, content_unit_id)`,
)}
create index asset_unit_coverage_unit_idx on asset_unit_coverage (content_unit_id);
${entityTable(
"asset_location",
`
media_asset_id uuid not null references media_asset (id) on delete cascade,
location_kind text not null default 'library' ${oneOf("location_kind", ["library", "download_cache", "hardlink", "external"])},
library_root_id uuid references library_root (id) on delete restrict,
downloader_instance_id uuid references downloader_instance (id) on delete set null,
relative_path text not null,
status text not null default 'available' ${oneOf("status", ["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"])},
is_primary boolean not null default false,
size_bytes bigint,
inode bigint,
device bigint,
mtime timestamptz,
sidecar_of_location_id uuid references asset_location (id) on delete set null,
last_seen_at timestamptz,
last_checked_at timestamptz,
check_detail jsonb not null default '{}'::jsonb,
constraint asset_location_library_root_check check (location_kind <> 'library' or library_root_id is not null)`,
)}
create unique index asset_location_root_path_key on asset_location (library_root_id, relative_path)
where status <> 'removed' and library_root_id is not null;
create index asset_location_asset_idx on asset_location (media_asset_id);
create index asset_location_check_idx on asset_location (last_checked_at) where status <> 'removed';
${entityTable(
"path_binding",
`
media_entity_id uuid not null references media_entity (id) on delete restrict,
content_unit_id uuid references content_unit (id) on delete restrict,
season_number integer,
binding_kind text not null ${oneOf("binding_kind", ["entity", "season"])},
library_root_id uuid not null references library_root (id) on delete restrict,
relative_path text not null,
template_version text,
status text not null default 'active' ${oneOf("status", ["active", "conflict", "superseded"])},
bound_at timestamptz not null default now(),
bound_by text,
superseded_by_id uuid references path_binding (id) on delete set null,
note text,
constraint path_binding_kind_check check ((binding_kind = 'season') = (season_number is not null))`,
)}
create index path_binding_entity_season_idx on path_binding (media_entity_id, season_number);
create unique index path_binding_active_key on path_binding (media_entity_id, season_number) nulls not distinct
where status = 'active';
create unique index path_binding_active_path_key on path_binding (library_root_id, relative_path)
where status = 'active';
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["path_binding", "asset_location", "asset_unit_coverage", "media_asset"]));
}
+123
View File
@@ -0,0 +1,123 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 规则域:subscription、policy_revision、acquisition_slot、preference_tag、preference_suppression(REQ §6)
// 每个 Slot 独立状态;策略版本化(草案/发布/回滚);取消的偏好留抑制记录,Agent 不得据旧证据自动加回。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"policy_revision",
`
policy_key text not null,
revision integer not null check (revision >= 1),
kind text not null ${oneOf("kind", ["acquisition", "preference", "retention", "cleanup", "instance", "automation"])},
status text not null default 'draft' ${oneOf("status", ["draft", "published", "superseded", "rolled_back", "archived"])},
title text not null,
summary text,
scope jsonb not null default '{}'::jsonb,
rules jsonb not null default '{}'::jsonb,
preview jsonb,
based_on_revision_id uuid references policy_revision (id) on delete set null,
created_by text,
published_at timestamptz,
published_by text,
constraint policy_revision_key unique (policy_key, revision)`,
)}
create unique index policy_revision_published_key on policy_revision (policy_key) where status = 'published';
${entityTable(
"subscription",
`
media_entity_id uuid references media_entity (id) on delete restrict,
season_unit_id uuid references content_unit (id) on delete restrict,
title_query text,
media_kind text not null ${oneOf("media_kind", ["movie", "series", "variety"])},
mode text not null default 'continuous' ${oneOf("mode", ["once", "continuous"])},
season_scope text not null default 'following_seasons' ${oneOf("season_scope", ["current_season", "following_seasons"])},
origin text not null default 'user' ${oneOf("origin", ["user", "agent", "policy", "import"])},
priority text not null default 'tracking' ${oneOf("priority", ["immediate", "tracking", "new_work", "upgrade"])},
status text not null default 'active' ${oneOf("status", ["active", "paused", "completed", "cancelled"])},
policy_revision_id uuid references policy_revision (id) on delete set null,
params jsonb not null default '{}'::jsonb,
status_reason text,
last_checked_at timestamptz,
created_by text,
constraint subscription_target_check check (media_entity_id is not null or title_query is not null)`,
)}
create index subscription_status_idx on subscription (status);
create index subscription_entity_idx on subscription (media_entity_id) where media_entity_id is not null;
${entityTable(
"acquisition_slot",
`
media_entity_id uuid not null references media_entity (id) on delete restrict,
content_unit_id uuid references content_unit (id) on delete restrict,
subscription_id uuid references subscription (id) on delete set null,
policy_revision_id uuid references policy_revision (id) on delete set null,
name text not null,
status text not null default 'empty' ${oneOf("status", ["empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending", "attention"])},
hard_conditions jsonb not null default '{}'::jsonb,
soft_preferences jsonb not null default '{}'::jsonb,
fallback jsonb not null default '{}'::jsonb,
upgrade_threshold jsonb not null default '{}'::jsonb,
completion jsonb not null default '{}'::jsonb,
distinct_group text,
require_distinct_asset boolean not null default false,
current_asset_id uuid references media_asset (id) on delete set null,
status_reason text,
last_evaluated_at timestamptz,
next_evaluate_at timestamptz,
revision integer not null default 1`,
)}
create unique index acquisition_slot_target_key on acquisition_slot (media_entity_id, content_unit_id, name) nulls not distinct;
create index acquisition_slot_status_idx on acquisition_slot (status);
create index acquisition_slot_next_evaluate_idx on acquisition_slot (next_evaluate_at) where next_evaluate_at is not null;
create index acquisition_slot_subscription_idx on acquisition_slot (subscription_id) where subscription_id is not null;
${entityTable(
"preference_tag",
`
label text not null,
kind text not null ${oneOf("kind", ["soft", "hard", "version", "target"])},
scope jsonb not null default '{}'::jsonb,
rule jsonb not null default '{}'::jsonb,
fingerprint text not null,
origin text not null default 'manual' ${oneOf("origin", ["manual", "conversation", "agent_inferred"])},
description text,
status text not null default 'active' ${oneOf("status", ["active", "disabled", "deleted"])},
revision integer not null default 1,
evidence jsonb not null default '{}'::jsonb,
policy_revision_id uuid references policy_revision (id) on delete set null`,
)}
create index preference_tag_status_idx on preference_tag (status);
create unique index preference_tag_fingerprint_live_key on preference_tag (fingerprint) where status <> 'deleted';
${entityTable(
"preference_suppression",
`
preference_tag_id uuid references preference_tag (id) on delete set null,
fingerprint text not null,
reason text,
evidence jsonb not null default '{}'::jsonb,
suppressed_by text not null,
active boolean not null default true,
lifted_at timestamptz,
lifted_by text,
constraint preference_suppression_lift_check check (active or lifted_at is not null)`,
)}
create index preference_suppression_active_idx on preference_suppression (fingerprint) where active;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
dropTables(["preference_suppression", "preference_tag", "acquisition_slot", "subscription", "policy_revision"]),
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 执行域:task(业务工作单/时间线投影)、acquisition_plan、download_task、asset_operation(REQ §8.4、§11)
// 引擎(OpenWorkflow)只管过程推进,业务状态以本域表为准(ADR-002)。
// 一个 Slot 同时只允许一个有效预留;download_task 以 (instance_id, torrent_hash) 唯一;
// asset_operation 是副作用账本,operation_id 由服务端按语义生成且唯一。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"task",
`
kind text not null check (kind ~ '^[a-z][A-Za-z0-9]*$'),
handler_version integer not null default 1,
idempotency_key text not null,
status text not null default 'pending' ${oneOf("status", ["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"])},
priority integer not null default 100,
input jsonb not null default '{}'::jsonb,
output jsonb,
subject_type text,
subject_id uuid,
parent_task_id uuid references task (id) on delete set null,
engine_run_id text,
attempts integer not null default 0,
max_attempts integer not null default 5,
next_run_at timestamptz not null default now(),
wait_reason text,
cancel_requested_at timestamptz,
cancel_reason text,
last_error jsonb,
started_at timestamptz,
finished_at timestamptz,
triggered_by text,
constraint task_idempotency_key unique (kind, idempotency_key)`,
)}
create index task_due_idx on task (status, next_run_at) where status in ('pending', 'waiting');
create index task_subject_idx on task (subject_type, subject_id) where subject_id is not null;
create index task_parent_idx on task (parent_task_id) where parent_task_id is not null;
create index task_created_idx on task (created_at desc);
${entityTable(
"acquisition_plan",
`
slot_id uuid not null references acquisition_slot (id) on delete restrict,
source_release_id uuid not null references source_release (id) on delete restrict,
task_id uuid references task (id) on delete set null,
purpose text not null default 'fill' ${oneOf("purpose", ["fill", "upgrade", "replace_source"])},
status text not null default 'draft' ${oneOf("status", ["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"])},
priority text not null default 'tracking' ${oneOf("priority", ["immediate", "tracking", "new_work", "upgrade"])},
evaluation jsonb not null default '{}'::jsonb,
reserved_bytes bigint not null default 0,
budget_reservation jsonb not null default '{}'::jsonb,
payload_hash text not null,
expected_revision integer not null default 1,
idempotency_key text not null,
expires_at timestamptz,
status_reason text,
constraint acquisition_plan_idempotency_key unique (idempotency_key)`,
)}
create unique index acquisition_plan_slot_active_key on acquisition_plan (slot_id) where status in ('reserved', 'submitted');
create index acquisition_plan_status_idx on acquisition_plan (status);
create index acquisition_plan_release_idx on acquisition_plan (source_release_id);
${entityTable(
"download_task",
`
instance_id uuid not null references downloader_instance (id) on delete restrict,
torrent_hash text not null check (torrent_hash ~ '^[0-9a-f]{40}$|^[0-9a-f]{64}$'),
infohash_v1 text,
infohash_v2 text,
acquisition_plan_id uuid references acquisition_plan (id) on delete set null,
source_release_id uuid references source_release (id) on delete set null,
name text not null,
save_path text,
content_path text,
category text,
tags text[] not null default '{}'::text[],
managed boolean not null default true,
origin text not null default 'submitted' ${oneOf("origin", ["submitted", "adopted"])},
status text not null default 'queued' ${oneOf("status", ["queued", "submitting", "downloading", "paused", "stalled", "completed", "importing", "imported", "failed", "removed", "missing"])},
pause_reason text ${oneOf("pause_reason", ["user", "threshold", "policy", "agent"])},
raw_state text,
progress double precision not null default 0 check (progress >= 0 and progress <= 1),
size_bytes bigint,
downloaded_bytes bigint not null default 0,
remaining_bytes bigint,
ratio double precision,
seeding_time_sec bigint,
completed_at timestamptz,
last_progress_at timestamptz,
no_progress_since timestamptz,
last_synced_at timestamptz,
next_check_at timestamptz,
removed_at timestamptz,
file_list jsonb,
last_error jsonb,
constraint download_task_instance_hash_key unique (instance_id, torrent_hash)`,
)}
create index download_task_status_idx on download_task (status);
create index download_task_next_check_idx on download_task (next_check_at) where next_check_at is not null;
create index download_task_plan_idx on download_task (acquisition_plan_id) where acquisition_plan_id is not null;
${entityTable(
"asset_operation",
`
operation_id text not null,
task_id uuid references task (id) on delete set null,
plan_fingerprint text,
ordinal integer not null default 0,
phase text not null ${oneOf("phase", ["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"])},
kind text not null ${oneOf("kind", ["copy", "move", "rename", "mkdir", "hardlink", "delete_source", "delete_target", "verify", "register", "qb_pause", "qb_resume", "qb_remove", "qb_set_tags", "other"])},
status text not null default 'planned' ${oneOf("status", ["planned", "started", "succeeded", "failed", "skipped", "compensated"])},
intent jsonb not null default '{}'::jsonb,
attempt_token uuid,
attempts integer not null default 0,
result jsonb,
error jsonb,
media_asset_id uuid references media_asset (id) on delete set null,
download_task_id uuid references download_task (id) on delete set null,
started_at timestamptz,
finished_at timestamptz,
constraint asset_operation_operation_id_key unique (operation_id)`,
)}
create index asset_operation_task_idx on asset_operation (task_id, ordinal) where task_id is not null;
create index asset_operation_unfinished_idx on asset_operation (updated_at) where status in ('planned', 'started');
alter table media_asset add column download_task_id uuid references download_task (id) on delete set null;
alter table acquisition_slot add column reserved_plan_id uuid references acquisition_plan (id) on delete set null;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table acquisition_slot drop column if exists reserved_plan_id;
alter table media_asset drop column if exists download_task_id;
${dropTables(["asset_operation", "download_task", "acquisition_plan", "task"])}
`,
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 人工/AI 域:decision(待处理中心)、approval、automation_grant、agent_run、artifact(REQ §10、§11.3、§11.4)
// 提案不直接晋升事实;审批绑定 plan ID + payload hash + expected revision;授权先落库再唤醒任务。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"agent_run",
`
task_id uuid references task (id) on delete set null,
parent_run_id uuid references agent_run (id) on delete set null,
trigger text not null ${oneOf("trigger", ["user", "event", "schedule", "subagent"])},
goal text not null,
status text not null default 'queued' ${oneOf("status", ["queued", "running", "waiting", "succeeded", "failed", "cancelled", "budget_exceeded"])},
provider text,
model text,
thinking_level text,
scope jsonb not null default '{}'::jsonb,
budget jsonb not null default '{}'::jsonb,
usage jsonb not null default '{}'::jsonb,
evidence_version text,
session_state jsonb,
summary text,
tool_call_count integer not null default 0,
last_event_seq bigint not null default 0,
error jsonb,
cancel_requested_at timestamptz,
started_at timestamptz,
finished_at timestamptz,
created_by text`,
)}
create index agent_run_status_idx on agent_run (status);
create index agent_run_task_idx on agent_run (task_id) where task_id is not null;
create index agent_run_created_idx on agent_run (created_at desc);
${entityTable(
"artifact",
`
kind text not null ${oneOf("kind", ["evidence", "plan", "proposal", "report", "snapshot", "log"])},
agent_run_id uuid references agent_run (id) on delete set null,
task_id uuid references task (id) on delete set null,
subject_type text,
subject_id uuid,
title text not null,
mime_type text not null default 'application/json',
content jsonb not null default '{}'::jsonb,
content_hash text not null,
size_bytes bigint,
expires_at timestamptz`,
)}
create index artifact_run_idx on artifact (agent_run_id) where agent_run_id is not null;
create index artifact_subject_idx on artifact (subject_type, subject_id) where subject_id is not null;
${entityTable(
"decision",
`
kind text not null ${oneOf("kind", ["identity_ambiguous", "download_stalled", "download_failed", "path_conflict", "space_insufficient", "approval_required", "metadata_conflict", "source_unreachable", "other"])},
status text not null default 'open' ${oneOf("status", ["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"])},
severity text not null default 'warning' ${oneOf("severity", ["info", "warning", "critical"])},
title text not null,
summary text,
subject_type text,
subject_id uuid,
dedupe_key text not null,
evidence jsonb not null default '{}'::jsonb,
options jsonb not null default '[]'::jsonb,
resolution jsonb,
task_id uuid references task (id) on delete set null,
agent_run_id uuid references agent_run (id) on delete set null,
attempts integer not null default 0,
next_review_at timestamptz,
resolved_by text,
resolved_at timestamptz`,
)}
create unique index decision_dedupe_open_key on decision (dedupe_key) where status in ('open', 'investigating', 'awaiting_user');
create index decision_status_idx on decision (status, created_at desc);
create index decision_next_review_idx on decision (next_review_at) where next_review_at is not null;
${entityTable(
"approval",
`
plan_type text not null,
plan_id uuid not null,
payload_hash text not null,
expected_revision integer not null,
scope jsonb not null default '{}'::jsonb,
status text not null default 'pending' ${oneOf("status", ["pending", "approved", "rejected", "revoked", "expired", "consumed"])},
decision_id uuid references decision (id) on delete set null,
requested_by text not null,
decided_by text,
decided_at timestamptz,
expires_at timestamptz,
reason text`,
)}
create index approval_plan_idx on approval (plan_type, plan_id);
create index approval_pending_idx on approval (created_at) where status = 'pending';
${entityTable(
"automation_grant",
`
action text not null,
policy_revision_id uuid references policy_revision (id) on delete set null,
scope jsonb not null default '{}'::jsonb,
budget jsonb not null default '{}'::jsonb,
status text not null default 'active' ${oneOf("status", ["active", "revoked", "expired"])},
granted_by text not null,
reason text,
expires_at timestamptz,
revoked_at timestamptz,
revoked_by text`,
)}
create index automation_grant_active_idx on automation_grant (action) where status = 'active';
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["automation_grant", "approval", "decision", "artifact", "agent_run"]));
}
+53
View File
@@ -0,0 +1,53 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 运维域:reconcile_run(五类校验器的可恢复运行记录)、server_mapping(Emby 兼容库存映射,REQ §7.4、§8.1)
// 外部库存"未知 ≠ 缺失";server_mapping 只是辅助证据,不构成文件纳管授权。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"reconcile_run",
`
kind text not null ${oneOf("kind", ["identity", "metadata", "asset", "acquisition", "server"])},
mode text not null default 'normal' ${oneOf("mode", ["normal", "read_only"])},
status text not null default 'queued' ${oneOf("status", ["queued", "running", "succeeded", "partial", "failed", "cancelled"])},
scope jsonb not null default '{}'::jsonb,
resume_cursor jsonb not null default '{}'::jsonb,
stats jsonb not null default '{}'::jsonb,
task_id uuid references task (id) on delete set null,
error jsonb,
started_at timestamptz,
finished_at timestamptz`,
)}
create index reconcile_run_kind_idx on reconcile_run (kind, created_at desc);
${entityTable(
"server_mapping",
`
media_server_instance_id uuid not null references media_server_instance (id) on delete cascade,
server_item_id text not null,
item_type text not null ${oneOf("item_type", ["movie", "series", "season", "episode", "other"])},
media_entity_id uuid references media_entity (id) on delete set null,
content_unit_id uuid references content_unit (id) on delete set null,
media_asset_id uuid references media_asset (id) on delete set null,
server_path text,
path_mapping_id uuid references path_mapping (id) on delete set null,
match_status text not null default 'unmatched' ${oneOf("match_status", ["matched", "ambiguous", "unmatched", "stale"])},
version_info jsonb not null default '{}'::jsonb,
evidence jsonb not null default '{}'::jsonb,
observed_at timestamptz not null default now(),
constraint server_mapping_item_key unique (media_server_instance_id, server_item_id)`,
)}
create index server_mapping_entity_idx on server_mapping (media_entity_id) where media_entity_id is not null;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["server_mapping", "reconcile_run"]));
}
+116
View File
@@ -0,0 +1,116 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 整理域:organization_record(批次)+ organization_file_result(逐文件)(REQ §12.4)
// change_seq:任何 update(含其文件明细变化)都会刷新为新的序列值,用于稳定游标增量读取;
// change_xid:最后一次变更的事务号。读游标时只返回 change_xid < pg_snapshot_xmin(pg_current_snapshot()) 的行,
// 避免"序号小但提交晚"的事务被游标跳过。
const RECORD_STATUS = ["queued", "running", "succeeded", "partial", "failed", "cancelled"] as const;
const ACTIONS = ["copy", "move", "rename", "relocate", "hardlink"] as const;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create sequence organization_record_change_seq as bigint;
${entityTable(
"organization_record",
`
task_id uuid references task (id) on delete set null,
trigger text not null ${oneOf("trigger", ["auto", "manual", "agent"])},
actor_type text not null ${oneOf("actor_type", ["user", "agent", "system", "api_key"])},
actor_id 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,
download_task_id uuid references download_task (id) on delete set null,
source_downloader_instance_id uuid references downloader_instance (id) on delete set null,
source_path_view text not null default 'local' ${oneOf("source_path_view", ["downloader", "local", "library"])},
source_path text,
target_library_root_id uuid references library_root (id) on delete set null,
target_path text,
action text not null ${oneOf("action", ACTIONS)},
status text not null default 'queued' ${oneOf("status", RECORD_STATUS)},
reason text,
error jsonb,
plan jsonb not null default '{}'::jsonb,
plan_fingerprint text,
file_total integer not null default 0,
file_succeeded integer not null default 0,
file_failed integer not null default 0,
file_skipped integer not null default 0,
started_at timestamptz,
finished_at timestamptz,
change_seq bigint not null default nextval('organization_record_change_seq'),
change_xid xid8 not null default pg_current_xact_id()`,
)}
alter sequence organization_record_change_seq owned by organization_record.change_seq;
create unique index organization_record_change_seq_key on organization_record (change_seq);
create index organization_record_entity_idx on organization_record (media_entity_id) where media_entity_id is not null;
create index organization_record_task_idx on organization_record (task_id) where task_id is not null;
create index organization_record_status_idx on organization_record (status, created_at desc);
create function organization_record_bump_change_seq() returns trigger language plpgsql as $$
begin
new.change_seq := nextval('organization_record_change_seq');
new.change_xid := pg_current_xact_id();
return new;
end $$;
create trigger organization_record_bump_change_seq before update on organization_record
for each row execute function organization_record_bump_change_seq();
${entityTable(
"organization_file_result",
`
organization_record_id uuid not null references organization_record (id) on delete cascade,
ordinal integer not null default 0,
asset_operation_id uuid references asset_operation (id) on delete set null,
media_asset_id uuid references media_asset (id) on delete set null,
content_unit_id uuid references content_unit (id) on delete set null,
role text not null default 'main' ${oneOf("role", ["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"])},
mode text not null ${oneOf("mode", ACTIONS)},
source_path text not null,
source_size_bytes bigint,
target_library_root_id uuid references library_root (id) on delete set null,
target_path text,
status text not null default 'queued' ${oneOf("status", ["queued", "running", "succeeded", "failed", "skipped", "cancelled"])},
skip_reason text,
error jsonb,
checksum text,
started_at timestamptz,
finished_at timestamptz`,
)}
create index organization_file_result_record_idx on organization_file_result (organization_record_id, ordinal);
-- 文件明细变化时刷新父记录 change_seq,使增量读取能拿到"既有记录状态更新"
create function organization_file_result_touch_record() returns trigger language plpgsql as $$
begin
update organization_record set updated_at = now()
where id = coalesce(new.organization_record_id, old.organization_record_id);
return null;
end $$;
create trigger organization_file_result_touch_record after insert or update or delete on organization_file_result
for each row execute function organization_file_result_touch_record();
alter table asset_operation add column organization_record_id uuid references organization_record (id) on delete set null;
create index asset_operation_record_idx on asset_operation (organization_record_id) where organization_record_id is not null;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
drop index if exists asset_operation_record_idx;
alter table asset_operation drop column if exists organization_record_id;
${dropTables(["organization_file_result", "organization_record"])}
drop function if exists organization_file_result_touch_record();
drop function if exists organization_record_bump_change_seq();
-- organization_record_change_seq 由 change_seq 列拥有,随表一起删除
`,
);
}