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

+108 -1
View File
@@ -1,3 +1,110 @@
# @umt/db
PostgreSQL 访问层:Kysely 客户端与连接池、手写的 DB 表类型(`src/types.ts`)、迁移文件(`migrations/`)与迁移器。表名 snake_case,与 REQ §12 实体名一致;所有 schema 变更只能通过新增迁移完成,已发布迁移不得修改。
PostgreSQL 访问层:Kysely 客户端与连接池、表类型(`src/types.ts`)、迁移文件(`migrations/`)与迁移器。表名 snake_case,与 REQ §12 实体名一致。
## 规则
- **迁移是唯一事实来源**。所有 schema 变更只能新增迁移,已发布迁移不得修改。迁移用原生 DDL 书写(`src/migration-helpers.ts`:`entityTable()` 统一加 uuid 主键 `gen_random_uuid()`、`created_at/updated_at` 与 updated_at 触发器;`oneOf()` 生成状态 CHECK)。`migrations/` 内相对导入必须带 `.ts` 扩展名(Kysely 用原生 `import()` 加载)。
- **`src/types.ts` 是生成物**:`pnpm db:migrate && pnpm --filter @umt/db gen:types`。CHECK 枚举自动变成字面量联合(如 `AcquisitionSlotStatus`)。`test/schema.test.ts` 会在隔离 schema 里重新反射并比对,漂移即失败;`packages/contracts/test/db-sync.test.ts` 再校验 contracts 的 Row schema 与枚举和这里一致。
- 状态列一律 `text + CHECK`,不用 PG enum(便于迁移加值)。JSONB 只放证据、规则参数、原文快照与演进字段;稳定查询字段用关系列。
- 凭证只存 `secret_ref`(→ `secret.id`),明文不入库、不进日志。
- `createDb()` 把 `int8` 解析为 `number`(超出 2^53 抛错)、`date` 保持 `YYYY-MM-DD` 字符串。写 jsonb 数组时须先 `JSON.stringify`(pg 会把 JS 数组当 PG 数组)。
- 测试对真实库(默认 `postgres://umt:umt@localhost:55433/umt`)在临时 schema 中跑迁移往返;无库环境设 `UMT_SKIP_DB_TESTS=1`。在非 public schema 迁移时要传 `createMigrator(db, { migrationTableSchema })`。
## 迁移清单
| 文件 | 内容 |
|---|---|
| `0001_init` | settings、api_key、audit_event、outbox_event |
| `0002_foundation` | pgcrypto / pg_trgm、`umt_touch_updated_at()`、secret;outbox 投递字段(ADR-002);api_key.updated_at |
| `0003_instances` | library_root、downloader_instance、media_server_instance、path_mapping |
| `0004_content` | media_entity、content_unit、title_alias |
| `0005_identity` | external_identity、identity_revision |
| `0006_sources` | site、site_account、feed_source、source_release、observation |
| `0007_assets` | media_asset、asset_unit_coverage、asset_location、path_binding |
| `0008_policy` | policy_revision、subscription、acquisition_slot、preference_tag、preference_suppression |
| `0009_execution` | task、acquisition_plan、download_task、asset_operation;回填 media_asset.download_task_id、acquisition_slot.reserved_plan_id |
| `0010_agent` | agent_run、artifact、decision、approval、automation_grant |
| `0011_ops` | reconcile_run、server_mapping |
| `0012_organization` | organization_record(change_seq 序列 + 触发器)、organization_file_result;回填 asset_operation.organization_record_id |
## 表清单(41 张)
### 底座 / 运维
| 表 | 用途与关键不变量 |
|---|---|
| `settings` | 全局设置 key→jsonb;key 为 `GlobalSettings` 的分组名(automation/cache/organize/retention/pi/budget) |
| `api_key` | API Key(只存 hash);scope ⊂ read/organize.read/organize.execute/admin,可撤销 |
| `audit_event` | 审计日志,只追加 |
| `outbox_event` | 业务事务内写入的事件;分发器扫 `status='pending' and next_attempt_at<=now()`,`idempotency_key` 唯一,作为引擎入队幂等键 |
| `secret` | 加密凭证(ciphertext/nonce/auth_tag/kdf/key_version);其他表只存 `secret_ref` |
| `reconcile_run` | 五类校验器的运行记录,`resume_cursor` 支持中断续扫;`mode=read_only` 用于恢复后对账 |
| `server_mapping` | Emby 兼容库存条目 ↔ 本地对象;`(media_server_instance_id, server_item_id)` 唯一;未知 ≠ 缺失,不构成纳管授权 |
### 实例
| 表 | 用途与关键不变量 |
|---|---|
| `library_root` | 媒体库根(执行进程可访问路径);`management_mode` register_only/managed,只登记范围不被全局规则隐式升级为自动下载 |
| `downloader_instance` | qB 实例;`managed_tag` 管理标签;`cache_volume_key` 聚合共用缓存卷的水位 |
| `media_server_instance` | Emby/Jellyfin 实例 |
| `path_mapping` | 下载器/媒体服务器路径前缀 → 本地路径;同实例同 remote_prefix 唯一;view_kind 与实例列一致(CHECK) |
### 内容 / 身份
| 表 | 用途与关键不变量 |
|---|---|
| `media_entity` | 本地作品,UUID 为主身份;合并时 `status=merged` 且 `merged_into_id` 指向目标(旧 ID 跳转);`field_locks` 人工锁字段刷新不得覆盖 |
| `content_unit` | 季/集/特别篇/分段;编号(season/episode/source_issue/air_date)可修订,不是唯一身份;`numbering_locked` 人工锁 |
| `title_alias` | 原名/译名/简称/站点原标题;`normalized` 上建 gin trgm 索引做模糊召回 |
| `external_identity` | 本地对象 ↔ 外部 ID;**`(provider, entity_type, external_id)` 对 accepted 部分唯一**,同一本地对象每个 provider+type 只有一个 accepted;候选(proposed/conflicted)可并存;404/超时只记 `verify_status` |
| `identity_revision` | 身份每次变更的修订历史,`(external_identity_id, revision)` 唯一,只追加 |
### 来源
| 表 | 用途与关键不变量 |
|---|---|
| `site` | 站点(adapter/限流/熔断/快照游标);关闭站点即停止网络访问与新候选 |
| `site_account` | 站点账号,凭证走 `secret_ref` |
| `feed_source` | RSS/列表/查询入口;真实 URL(含 passkey)只在 `url_secret_ref`,对外只给 `url_display`;按 `next_fetch_at` 调度 |
| `source_release` | 站点资源;**`(site_id, site_release_id)` 唯一**,多 Feed 观察汇到同一行;`source_published_at`/`first_seen_at` 分开 |
| `observation` | 一次原始观察;**`(feed_id, guid)` 唯一**,`content_digest` 变化即条目更新 |
### 文件
| 表 | 用途与关键不变量 |
|---|---|
| `media_asset` | 文件资产(与位置分离);附属文件 `parent_asset_id` 指向主视频;内容版本 `content_edition` 与交付特征 `delivery` 分开 |
| `asset_unit_coverage` | 资产覆盖哪些内容单元(合集/分段),`(media_asset_id, content_unit_id)` 唯一 |
| `asset_location` | 资产的一个位置;**同一库根 + 相对路径在非 removed 状态下唯一**;状态 available/unavailable/suspected_missing/confirmed_missing/removed |
| `path_binding` | 作品/季目录绑定;**一旦建立不因元数据刷新改变**,只有显式整理可替换(旧行标 superseded);同作品同季只有一个 active,同一路径只绑定一个 active;历史双目录用 `conflict` |
### 规则
| 表 | 用途与关键不变量 |
|---|---|
| `policy_revision` | 策略版本(草案/发布/回滚);`(policy_key, revision)` 唯一,每个 policy_key 只有一个 published |
| `subscription` | 追踪目标(单次/持续,仅本季/后续季,优先级);作品未建档时用 `title_query` |
| `acquisition_slot` | 独立收录目标;状态 empty/reserved/downloading/verifying/satisfied/upgrade_pending/attention;`(media_entity_id, content_unit_id, name)` 唯一(NULLS NOT DISTINCT) |
| `preference_tag` | 前端可编辑偏好标签;`fingerprint` 在未删除标签中唯一 |
| `preference_suppression` | 取消标签的抑制记录;`active` 时 Agent 不得据同一 fingerprint 自动加回 |
### 执行
| 表 | 用途与关键不变量 |
|---|---|
| `task` | 业务工作单/时间线投影(引擎只管推进);`(kind, idempotency_key)` 唯一;按 `(status, next_run_at)` 调度 |
| `acquisition_plan` | 下载计划 + Slot/预算预留;**每个 Slot 同时只能有一个 reserved/submitted 计划**(部分唯一);`idempotency_key` 唯一 |
| `download_task` | 下载器任务映射;**`(instance_id, torrent_hash)` 唯一**;`pause_reason` 区分用户/阈值暂停;`next_check_at` 对账调度 |
| `asset_operation` | 副作用账本;`operation_id` 唯一;`attempt_token` 做 fencing |
### 人工 / AI
| 表 | 用途与关键不变量 |
|---|---|
| `agent_run` | Agent 运行、预算/用量、会话状态、事件序号 |
| `artifact` | 证据/计划/提案产物;提案不直接晋升为事实 |
| `decision` | 待处理中心条目;同一 `dedupe_key` 同时只有一个未关闭条目 |
| `approval` | 单次审批,绑定 plan + payload_hash + expected_revision |
| `automation_grant` | 持续授权(动作 + 范围 + 预算 + 策略版本),可撤销 |
### 整理
| 表 | 用途与关键不变量 |
|---|---|
| `organization_record` | 整理批次(公开整理记录 API 的来源);`change_seq` 在任何 update(含文件明细变化)时取新序列值并唯一,`change_xid` 记录变更事务号 |
| `organization_file_result` | 逐文件前后路径与结果;增删改会刷新父记录 `change_seq` |
**游标读取**:`where change_seq > $after and change_xid < pg_snapshot_xmin(pg_current_snapshot()) order by change_seq limit $n`。第二个条件排除仍在进行中的事务,避免"序号小但提交晚"被跳过;返回的最大 `change_seq` 即 nextCursor。
+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 列拥有,随表一起删除
`,
);
}
+2 -1
View File
@@ -10,7 +10,8 @@
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run",
"migrate": "tsx --env-file-if-exists=../../.env scripts/migrate.ts latest",
"reset": "tsx --env-file-if-exists=../../.env scripts/migrate.ts reset"
"reset": "tsx --env-file-if-exists=../../.env scripts/migrate.ts reset",
"gen:types": "tsx --env-file-if-exists=../../.env scripts/gen-types.ts"
},
"dependencies": {
"kysely": "^0.29.6",
+140
View File
@@ -0,0 +1,140 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { type Kysely, sql } from "kysely";
// 从已迁移的数据库反射出 src/types.ts:列类型 + CHECK (col in (...)) 枚举字面量联合。
// 迁移是唯一事实来源;改表后运行 `pnpm --filter @umt/db gen:types`,测试会校验两者无漂移。
interface ColumnInfo {
table_name: string;
column_name: string;
data_type: string;
udt_name: string;
is_nullable: "YES" | "NO";
column_default: string | null;
}
const pascal = (s: string) =>
s
.split("_")
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join("");
const SCALAR: Record<string, string> = {
uuid: "string",
text: "string",
int4: "number",
int8: "number",
float8: "number",
bool: "boolean",
date: "string",
bytea: "Buffer",
xid8: "string",
timestamptz: "Timestamptz",
jsonb: "Json",
_text: "string[]",
};
/** 解析 `CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))` 为 { col, values } */
export function parseEnumCheck(def: string): { column: string; values: string[] } | null {
const single = /^CHECK \(\((\w+) = '((?:[^']|'')*)'::text\)\)$/.exec(def);
if (single) return { column: single[1]!, values: [single[2]!.replace(/''/g, "'")] };
const m = /^CHECK \(\(?\(?(\w+) = ANY \(\(?ARRAY\[(.+)\]\)?(?:::text\[\])?\)\)?\)?\)$/.exec(def);
if (!m) return null;
const values = [...m[2]!.matchAll(/'((?:[^']|'')*)'::text/g)].map((x) => x[1]!.replace(/''/g, "'"));
return values.length ? { column: m[1]!, values } : null;
}
// biome-ignore lint/suspicious/noExplicitAny: 反射不依赖应用表类型
export async function generateTypesSource(db: Kysely<any>): Promise<string> {
const cols = await sql<ColumnInfo>`
select table_name, column_name, data_type, udt_name, is_nullable, column_default
from information_schema.columns
where table_schema = current_schema() and table_name not like 'kysely_%'
order by table_name, ordinal_position`.execute(db);
const checks = await sql<{ table_name: string; def: string }>`
select c.relname as table_name, pg_get_constraintdef(k.oid) as def
from pg_constraint k join pg_class c on c.oid = k.conrelid
where k.contype = 'c' and c.relnamespace = current_schema()::regnamespace
order by c.relname, k.conname`.execute(db);
const enums = new Map<string, string[]>();
for (const { table_name, def } of checks.rows) {
const parsed = parseEnumCheck(def);
if (parsed) enums.set(`${table_name}.${parsed.column}`, parsed.values);
}
const tables = new Map<string, ColumnInfo[]>();
for (const c of cols.rows) {
const list = tables.get(c.table_name) ?? [];
list.push(c);
tables.set(c.table_name, list);
}
const out: string[] = [
"// 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。",
"// 改表:新增迁移 → pnpm db:migrate → pnpm --filter @umt/db gen:types。",
"",
'import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";',
"",
"/** timestamptz:读出为 Date,写入可传 Date 或 ISO 串 */",
"export type Timestamptz = ColumnType<Date, Date | string, Date | string>;",
"/** 有默认值的 timestamptz(Generated 不会展开嵌套 ColumnType,故单独定义) */",
"export type GeneratedTimestamptz = ColumnType<Date, Date | string | undefined, Date | string>;",
"/** jsonb:写入数组时须先 JSON.stringify(pg 会把 JS 数组当作 PG 数组) */",
"export type Json = unknown;",
"",
];
const enumAliases: string[] = [];
const body: string[] = [];
const names = [...tables.keys()].sort();
for (const t of names) {
body.push(`export interface ${pascal(t)}Table {`);
for (const c of tables.get(t)!) {
const key = `${t}.${c.column_name}`;
let ty: string;
const values = enums.get(key);
if (values) {
const alias = `${pascal(t)}${pascal(c.column_name)}`;
enumAliases.push(`export type ${alias} = ${values.map((v) => JSON.stringify(v)).join(" | ")};`);
ty = alias;
} else {
const s = SCALAR[c.udt_name];
if (!s) throw new Error(`未映射的列类型 ${key}: ${c.udt_name}`);
ty = s;
}
if (c.is_nullable === "YES") ty = `${ty} | null`;
if (c.column_default !== null) ty = ty === "Timestamptz" ? "GeneratedTimestamptz" : `Generated<${ty}>`;
body.push(` ${c.column_name}: ${ty};`);
}
body.push("}", "");
}
out.push(...enumAliases, "", ...body);
out.push("export interface DB {");
for (const t of names) out.push(` ${t}: ${pascal(t)}Table;`);
out.push("}", "");
for (const t of names) {
const p = pascal(t);
out.push(
`export type ${p}Row = Selectable<${p}Table>;`,
`export type New${p} = Insertable<${p}Table>;`,
`export type ${p}Update = Updateable<${p}Table>;`,
);
}
return `${out.join("\n")}\n`;
}
const ROOT = fileURLToPath(new URL("../../..", import.meta.url));
/** 用仓库的 biome 格式化生成结果,保证与 lint 一致 */
export function formatWithBiome(source: string): string {
return execFileSync(`${ROOT}node_modules/.bin/biome`, ["format", "--stdin-file-path=packages/db/src/types.ts"], {
input: source,
cwd: ROOT,
encoding: "utf8",
});
}
export const TYPES_FILE = fileURLToPath(new URL("../src/types.ts", import.meta.url));
+12
View File
@@ -0,0 +1,12 @@
// 用法:tsx scripts/gen-types.ts —— 对 DATABASE_URL 的当前 schema 反射并重写 src/types.ts
import { writeFileSync } from "node:fs";
import { createDb } from "../src";
import { formatWithBiome, generateTypesSource, TYPES_FILE } from "./gen-types-lib";
const db = createDb({ max: 1 });
try {
writeFileSync(TYPES_FILE, formatWithBiome(await generateTypesSource(db)));
console.log(`已写入 ${TYPES_FILE}`);
} finally {
await db.destroy();
}
+9
View File
@@ -2,6 +2,15 @@ import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import type { DB } from "./types";
// int8 → number(大小/序号都在 2^53 内;超出即报错而不是静默丢精度)
pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => {
const n = Number(v);
if (!Number.isSafeInteger(n)) throw new RangeError(`int8 超出安全整数范围: ${v}`);
return n;
});
// date → 原样 'YYYY-MM-DD' 字符串,避免按本地时区转成 Date 造成日期漂移
pg.types.setTypeParser(pg.types.builtins.DATE, (v: string) => v);
export interface CreateDbOptions {
/** 默认读 process.env.DATABASE_URL */
connectionString?: string;
+1 -1
View File
@@ -1,5 +1,5 @@
// 统一从本包取 Kysely 与 sql,避免各包各装一份 kysely
export { type Kysely, sql, type Transaction } from "kysely";
export { type CreateDbOptions, createDb } from "./client";
export { createMigrator, MIGRATIONS_DIR, migrateToLatest, resetSchema } from "./migrator";
export { createMigrator, MIGRATIONS_DIR, type MigratorOptions, migrateToLatest, resetSchema } from "./migrator";
export type * from "./types";
+37
View File
@@ -0,0 +1,37 @@
import { type Kysely, sql } from "kysely";
// 迁移辅助:迁移以原生 DDL 书写,便于评审与对照 REQ §12。
// 注意:已发布迁移引用的辅助函数行为不得再改变,只能新增函数。
/** 执行一段不带参数的 DDL(可含多条语句,pg 简单查询协议) */
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function execDdl(db: Kysely<any>, ddl: string): Promise<void> {
await sql.raw(ddl).execute(db);
}
/** text 列的枚举 CHECK:`check (col in ('a','b'))` */
export function oneOf(column: string, values: readonly string[]): string {
return `check (${column} in (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")}))`;
}
/**
* 标准实体表:uuid 主键 + created_at/updated_at + updated_at 自动刷新触发器。
* body 为其余列与表级约束(逗号分隔,不含首尾逗号)。
*/
export function entityTable(name: string, body: string): string {
return `
create table ${name} (
id uuid primary key default gen_random_uuid(),
${body.trim()},
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create trigger ${name}_touch_updated_at before update on ${name}
for each row execute function umt_touch_updated_at();
`;
}
/** 按顺序 drop 表(调用方按依赖逆序传入) */
export function dropTables(names: readonly string[]): string {
return names.map((n) => `drop table if exists ${n} cascade;`).join("\n");
}
+13 -3
View File
@@ -7,18 +7,28 @@ import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely
/** 迁移目录:packages/db/migrations,文件名 NNNN_name.ts,导出 up/down */
export const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
export interface MigratorOptions {
/**
* kysely_migration 表所在 schema。默认不指定(走 search_path)。
* 在非 public schema 里迁移(如测试隔离 schema)时必须显式指定,
* 否则 Kysely 会把 public 下已存在的迁移表当成当前库的迁移记录。
*/
migrationTableSchema?: string;
}
// biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型
export function createMigrator(db: Kysely<any>): Migrator {
export function createMigrator(db: Kysely<any>, opts: MigratorOptions = {}): Migrator {
return new Migrator({
db,
provider: new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR }),
...(opts.migrationTableSchema ? { migrationTableSchema: opts.migrationTableSchema } : {}),
});
}
/** 迁移到最新;失败时抛出 */
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function migrateToLatest(db: Kysely<any>): Promise<MigrationResultSet> {
const result = await createMigrator(db).migrateToLatest();
export async function migrateToLatest(db: Kysely<any>, opts: MigratorOptions = {}): Promise<MigrationResultSet> {
const result = await createMigrator(db, opts).migrateToLatest();
if (result.error) throw result.error;
return result;
}
+1094 -17
View File
File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
import { readFileSync } from "node:fs";
import { Kysely, PostgresDialect, sql } from "kysely";
import { NO_MIGRATIONS } from "kysely/migration";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { formatWithBiome, generateTypesSource, TYPES_FILE } from "../scripts/gen-types-lib";
import { createMigrator, type DB, migrateToLatest } from "../src";
// 对真实 PG(默认开发库 55433)跑迁移往返;每次测试用独立 schema,互不干扰且不碰 public。
// 无数据库环境可设 UMT_SKIP_DB_TESTS=1 跳过(CI 默认要求数据库可用)。
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
const skip = process.env.UMT_SKIP_DB_TESTS === "1";
const schema = `umt_test_${process.pid}_${Date.now().toString(36)}`;
const EXPECTED_TABLES = [
// 底座(0001/0002)
"settings",
"api_key",
"audit_event",
"outbox_event",
"secret",
// 实例
"library_root",
"downloader_instance",
"media_server_instance",
"path_mapping",
// 内容 / 身份
"media_entity",
"content_unit",
"title_alias",
"external_identity",
"identity_revision",
// 来源
"site",
"site_account",
"feed_source",
"source_release",
"observation",
// 文件
"media_asset",
"asset_unit_coverage",
"asset_location",
"path_binding",
// 规则
"policy_revision",
"subscription",
"acquisition_slot",
"preference_tag",
"preference_suppression",
// 执行
"task",
"acquisition_plan",
"download_task",
"asset_operation",
// 人工/AI
"agent_run",
"artifact",
"decision",
"approval",
"automation_grant",
// 运维 / 整理
"reconcile_run",
"server_mapping",
"organization_record",
"organization_file_result",
].sort();
describe.skipIf(skip)("schema migrations (real PG)", () => {
let admin: Kysely<unknown>;
let db: Kysely<DB>;
beforeAll(async () => {
admin = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 1 }) }) });
await sql.raw(`create schema ${schema}`).execute(admin);
db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
});
afterAll(async () => {
await db?.destroy();
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
await admin?.destroy();
});
const tables = async () =>
(
await sql<{ t: string }>`select table_name as t from information_schema.tables
where table_schema = current_schema() and table_type = 'BASE TABLE' and table_name not like 'kysely_%'
order by 1`.execute(db)
).rows.map((r) => r.t);
it("建齐任务书 §3.1-2 全部表", async () => {
expect(await tables()).toEqual(EXPECTED_TABLES);
});
it("src/types.ts 与迁移结果一致(无漂移)", async () => {
const generated = formatWithBiome(await generateTypesSource(db));
expect(generated).toBe(readFileSync(TYPES_FILE, "utf8"));
});
it("新实体表都有 uuid 主键默认值与 created_at/updated_at", async () => {
const rows = (
await sql<{ table_name: string; column_name: string; column_default: string | null }>`
select table_name, column_name, column_default from information_schema.columns
where table_schema = current_schema() and column_name in ('id', 'created_at', 'updated_at')`.execute(db)
).rows;
for (const t of EXPECTED_TABLES.filter((x) => !["settings", "audit_event", "outbox_event"].includes(x))) {
const cols = rows.filter((r) => r.table_name === t);
expect(cols.map((c) => c.column_name).sort(), t).toEqual(["created_at", "id", "updated_at"]);
expect(cols.find((c) => c.column_name === "id")?.column_default, t).toBe("gen_random_uuid()");
}
});
it("updated_at 触发器与 CHECK 状态约束", async () => {
const e = await db
.insertInto("media_entity")
.values({ kind: "movie", title: "沙丘2", year: 2024 })
.returningAll()
.executeTakeFirstOrThrow();
await sql`select pg_sleep(0.01)`.execute(db);
const u = await db
.updateTable("media_entity")
.set({ title: "沙丘:第二部" })
.where("id", "=", e.id)
.returningAll()
.executeTakeFirstOrThrow();
expect(u.updated_at.getTime()).toBeGreaterThanOrEqual(e.updated_at.getTime());
await expect(
sql`insert into acquisition_slot (media_entity_id, name, status) values (${e.id}, 'x', 'bogus')`.execute(db),
).rejects.toThrow(/check constraint/);
});
it("external_identity:accepted 唯一,候选可并存", async () => {
const [a, b] = await db
.insertInto("media_entity")
.values([
{ kind: "movie", title: "A" },
{ kind: "movie", title: "B" },
])
.returning("id")
.execute();
const base = { provider: "tmdb", entity_type: "movie" as const, external_id: "693134" };
await db
.insertInto("external_identity")
.values([
{ ...base, media_entity_id: a!.id, status: "proposed" },
{ ...base, media_entity_id: b!.id, status: "proposed" },
{ ...base, media_entity_id: a!.id, status: "accepted" },
{ ...base, entity_type: "tv", media_entity_id: b!.id, status: "accepted" },
])
.execute();
await expect(
db
.insertInto("external_identity")
.values({ ...base, media_entity_id: b!.id, status: "accepted" })
.execute(),
).rejects.toThrow(/external_identity_accepted_key/);
});
it("source_release / observation / download_task / Slot 预留 唯一约束", async () => {
const site = await db
.insertInto("site")
.values({ key: "audiences", name: "观众", adapter: "nexusphp", base_url: "https://example.invalid" })
.returning("id")
.executeTakeFirstOrThrow();
const rel = await db
.insertInto("source_release")
.values({ site_id: site.id, site_release_id: "123", title: "Dune.Part.Two.2024.2160p" })
.returning("id")
.executeTakeFirstOrThrow();
await expect(
db.insertInto("source_release").values({ site_id: site.id, site_release_id: "123", title: "dup" }).execute(),
).rejects.toThrow(/source_release_site_release_key/);
const feed = await db
.insertInto("feed_source")
.values({ site_id: site.id, name: "电影", url_display: "https://example.invalid/rss?passkey=[REDACTED]" })
.returning("id")
.executeTakeFirstOrThrow();
const obs = { feed_id: feed.id, guid: "g1", content_digest: "d1", title: "t" };
await db.insertInto("observation").values(obs).execute();
await expect(db.insertInto("observation").values(obs).execute()).rejects.toThrow(/observation_feed_guid_key/);
const qb = await db
.insertInto("downloader_instance")
.values({ name: "qb1", base_url: "http://qb.invalid" })
.returning("id")
.executeTakeFirstOrThrow();
const dt = { instance_id: qb.id, torrent_hash: "a".repeat(40), name: "x" };
await db.insertInto("download_task").values(dt).execute();
await expect(db.insertInto("download_task").values(dt).execute()).rejects.toThrow(
/download_task_instance_hash_key/,
);
const ent = await db
.insertInto("media_entity")
.values({ kind: "movie", title: "C" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await db
.insertInto("acquisition_slot")
.values({ media_entity_id: ent.id, name: "archival" })
.returning("id")
.executeTakeFirstOrThrow();
await expect(
db.insertInto("acquisition_slot").values({ media_entity_id: ent.id, name: "archival" }).execute(),
).rejects.toThrow(/acquisition_slot_target_key/);
const plan = (k: string) => ({
slot_id: slot.id,
source_release_id: rel.id,
status: "reserved" as const,
payload_hash: "h",
idempotency_key: k,
});
await db.insertInto("acquisition_plan").values(plan("k1")).execute();
await expect(db.insertInto("acquisition_plan").values(plan("k2")).execute()).rejects.toThrow(
/acquisition_plan_slot_active_key/,
);
});
it("path_binding:同作品同季只能有一个 active 绑定", async () => {
const root = await db
.insertInto("library_root")
.values({ name: "movies", local_path: "/media/movies" })
.returning("id")
.executeTakeFirstOrThrow();
const ent = await db
.insertInto("media_entity")
.values({ kind: "series", title: "S" })
.returning("id")
.executeTakeFirstOrThrow();
const b = { media_entity_id: ent.id, library_root_id: root.id, binding_kind: "entity" as const };
await db
.insertInto("path_binding")
.values({ ...b, relative_path: "S (2023)" })
.execute();
await expect(
db
.insertInto("path_binding")
.values({ ...b, relative_path: "S2 (2023)" })
.execute(),
).rejects.toThrow(/path_binding_active_key/);
await db
.insertInto("path_binding")
.values({ ...b, relative_path: "S2 (2023)", status: "conflict" })
.execute();
});
it("organization_record.change_seq 随更新与文件明细变化递增", async () => {
const rec = await db
.insertInto("organization_record")
.values({ trigger: "auto", actor_type: "system", action: "copy" })
.returningAll()
.executeTakeFirstOrThrow();
const upd = await db
.updateTable("organization_record")
.set({ status: "running" })
.where("id", "=", rec.id)
.returning("change_seq")
.executeTakeFirstOrThrow();
expect(upd.change_seq).toBeGreaterThan(rec.change_seq);
await db
.insertInto("organization_file_result")
.values({ organization_record_id: rec.id, mode: "copy", source_path: "/downloads/a.mkv" })
.execute();
const after = await db
.selectFrom("organization_record")
.select("change_seq")
.where("id", "=", rec.id)
.executeTakeFirstOrThrow();
expect(after.change_seq).toBeGreaterThan(upd.change_seq);
expect(typeof after.change_seq).toBe("number");
});
it("title_alias 上有 gin trgm 索引,可模糊召回", async () => {
const ent = await db
.insertInto("media_entity")
.values({ kind: "series", title: "最后生还者" })
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("title_alias")
.values({ media_entity_id: ent.id, alias: "The Last of Us", normalized: "the last of us", source: "tmdb" })
.execute();
const hit = await sql<{ id: string }>`select media_entity_id as id from title_alias
where normalized % ${"last of us"}`.execute(db);
expect(hit.rows.map((r) => r.id)).toContain(ent.id);
});
it("迁移可完整 down 再 up(往返)", async () => {
const migrator = createMigrator(db, { migrationTableSchema: schema });
const down = await migrator.migrateTo(NO_MIGRATIONS);
expect(down.error).toBeUndefined();
expect(await tables()).toEqual([]);
const up = await migrator.migrateToLatest();
expect(up.error).toBeUndefined();
expect(await tables()).toEqual(EXPECTED_TABLES);
});
});
+5
View File
@@ -1,4 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// 迁移文件由 Kysely FileMigrationProvider 用原生 import() 加载(Node 类型剥离,不做无扩展名解析),
// 因此 migrations/ 内相对导入必须写 .ts 扩展名
"allowImportingTsExtensions": true
},
"include": ["src", "test", "scripts", "migrations"]
}