# @umt/db 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` 是生成物**:`deno task db:migrate && deno task 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 | | `0013_tasks_tables` | 并入 @umt/tasks 自有表:tasks_run_meta、tasks_fact、tasks_op_ledger(if not exists,可接管 W1 旧库) | | `0014_agent_run_event` | agent_run_event:Agent 运行事件,主键 (run_id, seq),删 run 级联 | | `0018_sources_snapshot` | site.adapter 增加 `api`;source_release.resolution_locked(人工锁);site_snapshot(快照/补漏/实时补查的覆盖范围) | | `0019_identity_pool` | identity_candidate(待识别池)、resolution_attempt(识别尝试)、identity_redirect(合并跳转) | | `0020_metadata_fields` | metadata_field(来源候选字段)、field_override(用户覆盖与锁定) | ## 表清单(45 张) ### 底座 / 运维 | 表 | 用途与关键不变量 | |---|---| | `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)` 唯一,只追加 | | `identity_candidate` | 待识别池:每条 source_release 至多一条;候选只存 JSON(不建正式作品),保存原因/证据版本/复查时间(有界退避) | | `resolution_attempt` | 每次识别尝试的结果与证据版本,只追加 | | `identity_redirect` | 作品合并后的旧 ID 跳转,`from_entity_id` 唯一 | | `metadata_field` | 各来源的候选字段值(来源/语言/时间/来源修订);刷新只写这里 | | `field_override` | 用户字段覆盖与锁定,优先于自动来源;目标 + 字段唯一 | ### 来源 | 表 | 用途与关键不变量 | |---|---| | `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` 变化即条目更新 | | `site_snapshot` | 一次列表快照 / 补漏 / 实时补查:页码、条目数、状态与错误(新鲜度来源) | ### 文件 | 表 | 用途与关键不变量 | |---|---| | `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` | 持续授权(动作 + 范围 + 预算 + 策略版本),可撤销 | | `agent_run_event` | Agent 运行事件(9 种,见 contracts `AgentRunEventType`);**(run_id, seq) 主键**,先落库再推 SSE;payload = 事件主体去掉 type | ### 任务引擎(@umt/tasks 内部使用,不经 API 暴露) | 表 | 用途与关键不变量 | |---|---| | `tasks_run_meta` | 引擎 run 的业务侧元数据(traceId/优先级/取消标志) | | `tasks_fact` | signal 前先落库的事实,waitForFact 以它为准 | | `tasks_op_ledger` | 操作账本 fencing:attempt_token,旧 attempt 提交被拒 | ### 整理 | 表 | 用途与关键不变量 | |---|---| | `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。