Files
UHD_Media_Tool/packages/db/migrations/0007_assets.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

100 lines
4.7 KiB
TypeScript

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"]));
}