- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
enable/disable (history stays queryable, marked site_disabled), accounts and
feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
feed+guid upsert with digest-change updates, source_release site+id
aggregation across feeds, auth failure -> account invalid + decision, no
retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
accepted identities -> TMDB -> resolveIdentity; auto binds entity /
provisional units / identities / aliases, otherwise identity_candidate pool
with evidence version and bounded review backoff; manual lock respected),
refreshMetadata (metadata_field + field_override, user locks win, paths
untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
identity_revision, merge with identity_redirect, pool list/assign/dismiss/
retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
metadata_field, field_override.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
130 lines
10 KiB
Markdown
130 lines
10 KiB
Markdown
# @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。
|