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

+15
View File
@@ -1,3 +1,18 @@
# @umt/contracts
所有 API、SSE 事件与能力(capability)输入输出的唯一定义(zod 4 schema + 推导出的 TS 类型)。web 与 server 共用,任何一端都不得手写重复 DTO;新增接口先在这里加 schema,再由 server 用 `@hono/zod-openapi` 生成 OpenAPI。
| 文件 | 内容 |
|---|---|
| `common.ts` | Id、Timestamp、DateOnly、JsonObject、游标分页、错误码、响应信封、ApiScope、ActorType |
| `content.ts` / `identity.ts` / `assets.ts` / `sources.ts` / `policy.ts` / `execution.ts` / `agent.ts` / `ops.ts` / `organization.ts` / `instances.ts` | 每张表的 Row schema(camelCase、日期为 ISO 字符串)+ `XxxCreate` / `XxxUpdate` 输入 |
| `organization.ts` | 另含公开整理记录合同:`PublicOrganizationRecord`、`OrganizationChangesQuery/Response`(change_seq 游标)、`OrganizeRequest` |
| `settings.ts` | `GlobalSettings`、`DEFAULT_SETTINGS`、`GlobalSettingsUpdate`、`mergeSettings()` |
| `capability.ts` / `capabilities-io.ts` | `CapabilityDescriptor`、首批 14 个能力 ID 与目录、各能力 Input/Output schema、`CAPABILITY_IO` |
| `events.ts` | SSE 信封、`EventType`、`EVENT_PAYLOADS`、`TypedEvent` 判别联合 |
| `api.ts` | `Api` 端点表(method/path/scope/params/query/body/response)、`buildPath()`、`listEndpoints()` |
约定:
- Row schema 与 `packages/db/src/types.ts` 的列一一对应(`test/db-sync.test.ts` 校验);只在 DB 存在的列(密文、key_hash、change_xid)不对外。
- 凭证类输入(`credential`、`url`、`plaintext`、`apiKey`)只写不回显。
- schema 不用 `.default()` 做可部分更新对象的缺省(zod 4 在 partial 里仍会填默认值);缺省值放常量。
+284
View File
@@ -0,0 +1,284 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 人工/AI 域:Agent 运行、证据/计划产物、待处理事项、审批、持续授权(REQ §10、§11.3、§11.4)
export const AgentTrigger = z.enum(["user", "event", "schedule", "subagent"]);
export type AgentTrigger = z.infer<typeof AgentTrigger>;
export const AgentRunStatus = z.enum([
"queued",
"running",
"waiting",
"succeeded",
"failed",
"cancelled",
"budget_exceeded",
]);
export type AgentRunStatus = z.infer<typeof AgentRunStatus>;
/** 单次运行预算(调用次数/token/时长/费用,REQ §10.5) */
export const AgentBudget = z.object({
maxToolCalls: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
maxDurationSec: z.number().int().positive().optional(),
maxCostUsd: z.number().nonnegative().optional(),
});
export type AgentBudget = z.infer<typeof AgentBudget>;
export const AgentUsage = z.object({
inputTokens: NonNegInt.optional(),
outputTokens: NonNegInt.optional(),
cacheReadTokens: NonNegInt.optional(),
cacheWriteTokens: NonNegInt.optional(),
costUsd: z.number().nonnegative().optional(),
durationMs: NonNegInt.optional(),
});
export type AgentUsage = z.infer<typeof AgentUsage>;
export const AgentRun = EntityBase.extend({
taskId: Id.nullable(),
parentRunId: Id.nullable(),
trigger: AgentTrigger,
goal: z.string().min(1),
status: AgentRunStatus,
provider: z.string().nullable(),
model: z.string().nullable(),
thinkingLevel: z.string().nullable(),
/** 授权范围(能力白名单、对象范围);子代理继承父任务上限 */
scope: JsonObject,
budget: AgentBudget,
usage: AgentUsage,
evidenceVersion: z.string().nullable(),
/** Pi 会话持久化状态;不对普通列表返回 */
sessionState: JsonObject.nullable(),
summary: z.string().nullable(),
toolCallCount: NonNegInt,
lastEventSeq: NonNegInt,
error: JsonObject.nullable(),
cancelRequestedAt: Timestamp.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type AgentRun = z.infer<typeof AgentRun>;
export const AgentRunCreate = AgentRun.pick({
taskId: true,
parentRunId: true,
trigger: true,
goal: true,
scope: true,
budget: true,
}).partial({ taskId: true, parentRunId: true, trigger: true, scope: true, budget: true });
export type AgentRunCreate = z.infer<typeof AgentRunCreate>;
export const AgentRunUpdate = AgentRun.pick({
status: true,
provider: true,
model: true,
thinkingLevel: true,
usage: true,
evidenceVersion: true,
sessionState: true,
summary: true,
toolCallCount: true,
lastEventSeq: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AgentRunUpdate = z.infer<typeof AgentRunUpdate>;
export const ArtifactKind = z.enum(["evidence", "plan", "proposal", "report", "snapshot", "log"]);
export type ArtifactKind = z.infer<typeof ArtifactKind>;
/** 证据/计划/提案产物:提案不直接晋升为事实 */
export const Artifact = EntityBase.extend({
kind: ArtifactKind,
agentRunId: Id.nullable(),
taskId: Id.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
title: z.string().min(1),
mimeType: z.string(),
content: JsonObject,
contentHash: z.string().min(1),
sizeBytes: NonNegInt.nullable(),
expiresAt: Timestamp.nullable(),
});
export type Artifact = z.infer<typeof Artifact>;
/** contentHash / sizeBytes 由服务端计算 */
export const ArtifactCreate = Artifact.omit({ ...SERVER_MANAGED, contentHash: true, sizeBytes: true }).partial({
agentRunId: true,
taskId: true,
subjectType: true,
subjectId: true,
mimeType: true,
expiresAt: true,
});
export type ArtifactCreate = z.infer<typeof ArtifactCreate>;
/** 产物内容不可变;只能调整过期时间 */
export const ArtifactUpdate = z.object({ expiresAt: Timestamp.nullable() });
export type ArtifactUpdate = z.infer<typeof ArtifactUpdate>;
export const DecisionKind = z.enum([
"identity_ambiguous",
"download_stalled",
"download_failed",
"path_conflict",
"space_insufficient",
"approval_required",
"metadata_conflict",
"source_unreachable",
"other",
]);
export type DecisionKind = z.infer<typeof DecisionKind>;
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
export type DecisionStatus = z.infer<typeof DecisionStatus>;
export const Severity = z.enum(["info", "warning", "critical"]);
export type Severity = z.infer<typeof Severity>;
export const DecisionOption = z.object({
key: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
/** 选择后执行的能力与参数(仍经授权校验) */
capabilityId: z.string().optional(),
input: JsonObject.optional(),
});
export type DecisionOption = z.infer<typeof DecisionOption>;
/** 待处理中心条目;同一 dedupeKey 同时只有一个未关闭条目 */
export const Decision = EntityBase.extend({
kind: DecisionKind,
status: DecisionStatus,
severity: Severity,
title: z.string().min(1),
summary: z.string().nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
dedupeKey: z.string().min(1),
evidence: JsonObject,
options: z.array(DecisionOption),
resolution: JsonObject.nullable(),
taskId: Id.nullable(),
agentRunId: Id.nullable(),
attempts: NonNegInt,
nextReviewAt: Timestamp.nullable(),
resolvedBy: z.string().nullable(),
resolvedAt: Timestamp.nullable(),
});
export type Decision = z.infer<typeof Decision>;
export const DecisionCreate = Decision.pick({
kind: true,
severity: true,
title: true,
summary: true,
subjectType: true,
subjectId: true,
dedupeKey: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
}).partial({
severity: true,
summary: true,
subjectType: true,
subjectId: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
});
export type DecisionCreate = z.infer<typeof DecisionCreate>;
export const DecisionUpdate = Decision.pick({
status: true,
severity: true,
summary: true,
evidence: true,
options: true,
agentRunId: true,
nextReviewAt: true,
}).partial();
export type DecisionUpdate = z.infer<typeof DecisionUpdate>;
/** 用户处理待处理事项:选一个选项或关闭 */
export const DecisionResolveInput = z.object({
optionKey: z.string().min(1).optional(),
dismiss: z.boolean().optional(),
note: z.string().max(2000).optional(),
});
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
export const ApprovalStatus = z.enum(["pending", "approved", "rejected", "revoked", "expired", "consumed"]);
export type ApprovalStatus = z.infer<typeof ApprovalStatus>;
/** 单次审批:绑定 planType+planId + payloadHash + expectedRevision;换候选/路径后旧审批失效 */
export const Approval = EntityBase.extend({
planType: z.string().min(1),
planId: Id,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(0),
scope: JsonObject,
status: ApprovalStatus,
decisionId: Id.nullable(),
requestedBy: z.string().min(1),
decidedBy: z.string().nullable(),
decidedAt: Timestamp.nullable(),
expiresAt: Timestamp.nullable(),
reason: z.string().nullable(),
});
export type Approval = z.infer<typeof Approval>;
export const ApprovalCreate = Approval.pick({
planType: true,
planId: true,
payloadHash: true,
expectedRevision: true,
scope: true,
decisionId: true,
requestedBy: true,
expiresAt: true,
reason: true,
}).partial({ scope: true, decisionId: true, expiresAt: true, reason: true });
export type ApprovalCreate = z.infer<typeof ApprovalCreate>;
/** 批准/拒绝时回传 payloadHash,服务端比对防止审批被挪用到变更后的计划 */
export const ApprovalUpdate = z.object({
status: z.enum(["approved", "rejected", "revoked"]),
payloadHash: z.string().min(1),
reason: z.string().max(2000).optional(),
});
export type ApprovalUpdate = z.infer<typeof ApprovalUpdate>;
export const GrantStatus = z.enum(["active", "revoked", "expired"]);
export type GrantStatus = z.infer<typeof GrantStatus>;
/** 持续授权:绑定策略版本、动作、对象范围与预算 */
export const AutomationGrant = EntityBase.extend({
action: z.string().min(1),
policyRevisionId: Id.nullable(),
scope: JsonObject,
budget: JsonObject,
status: GrantStatus,
grantedBy: z.string().min(1),
reason: z.string().nullable(),
expiresAt: Timestamp.nullable(),
revokedAt: Timestamp.nullable(),
revokedBy: z.string().nullable(),
});
export type AutomationGrant = z.infer<typeof AutomationGrant>;
export const AutomationGrantCreate = AutomationGrant.pick({
action: true,
policyRevisionId: true,
scope: true,
budget: true,
reason: true,
expiresAt: true,
}).partial({ policyRevisionId: true, scope: true, budget: true, reason: true, expiresAt: true });
export type AutomationGrantCreate = z.infer<typeof AutomationGrantCreate>;
/** 只能撤销或缩短有效期,不能就地扩大范围(扩大 = 新授权) */
export const AutomationGrantUpdate = z.object({
status: z.literal("revoked").optional(),
expiresAt: Timestamp.nullable().optional(),
reason: z.string().max(2000).optional(),
});
export type AutomationGrantUpdate = z.infer<typeof AutomationGrantUpdate>;
+783
View File
@@ -0,0 +1,783 @@
import { z } from "zod";
import * as agent from "./agent";
import * as assets from "./assets";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability";
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common";
import * as content from "./content";
import * as execution from "./execution";
import * as identity from "./identity";
import * as instances from "./instances";
import * as ops from "./ops";
import * as organization from "./organization";
import * as policy from "./policy";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings";
import * as sources from "./sources";
// 所有 REST 端点的路径常量与请求/响应 schema 汇总。
// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用);
// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。
export const API_PREFIX = "/api/v1";
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
export interface EndpointDef {
method: HttpMethod;
path: string;
scope: ApiScope;
summary: string;
params?: z.ZodObject;
query?: z.ZodType;
body?: z.ZodType;
response: z.ZodType;
}
const ep = <E extends EndpointDef>(e: E): E => e;
/** 列表查询:游标分页 + 资源自有过滤 */
const listQuery = <S extends z.ZodRawShape>(filters: S) => CursorQuery.extend(filters);
/** 删除/无内容动作的响应 */
export const OkResult = z.object({ ok: z.literal(true) });
interface CrudSchemas {
row: z.ZodType;
create?: z.ZodType;
update?: z.ZodType;
filters?: z.ZodRawShape;
}
/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */
function crud<const B extends string>(
base: B,
name: string,
s: CrudSchemas,
scopes: { read: ApiScope; write: ApiScope },
) {
const item = `${base}/{id}` as const;
return {
list: ep({
method: "get",
path: base,
scope: scopes.read,
summary: `${name}列表`,
query: listQuery(s.filters ?? {}),
response: cursorPage(s.row),
}),
get: ep({
method: "get",
path: item,
scope: scopes.read,
summary: `${name}详情`,
params: IdParam,
response: s.row,
}),
...(s.create
? {
create: ep({
method: "post",
path: base,
scope: scopes.write,
summary: `新建${name}`,
body: s.create,
response: s.row,
}),
}
: {}),
...(s.update
? {
update: ep({
method: "patch",
path: item,
scope: scopes.write,
summary: `修改${name}`,
params: IdParam,
body: s.update,
response: s.row,
}),
}
: {}),
};
}
const R = { read: "read", write: "admin" } as const;
const del = (path: string, summary: string) =>
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
export const Api = {
// ── 内容 / 身份 ──
mediaEntities: {
...crud(
"/media-entities",
"作品",
{
row: content.MediaEntity,
create: content.MediaEntityCreate,
update: content.MediaEntityUpdate,
filters: {
kind: content.MediaKind.optional(),
status: content.MediaEntityStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
merge: ep({
method: "post",
path: "/media-entities/{id}/merge",
scope: "admin",
summary: "合并重复作品(保留旧 ID 跳转)",
params: IdParam,
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
response: content.MediaEntity,
}),
},
contentUnits: crud(
"/content-units",
"内容单元",
{
row: content.ContentUnit,
create: content.ContentUnitCreate,
update: content.ContentUnitUpdate,
filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
},
R,
),
titleAliases: {
...crud(
"/title-aliases",
"别名",
{
row: content.TitleAlias,
create: content.TitleAliasCreate,
update: content.TitleAliasUpdate,
filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
},
R,
),
remove: del("/title-aliases/{id}", "删除别名"),
},
externalIdentities: {
...crud(
"/external-identities",
"外部身份",
{
row: identity.ExternalIdentity,
create: identity.ExternalIdentityCreate,
update: identity.ExternalIdentityUpdate,
filters: {
mediaEntityId: Id.optional(),
provider: z.string().optional(),
status: identity.IdentityStatus.optional(),
},
},
R,
),
revisions: ep({
method: "get",
path: "/external-identities/{id}/revisions",
scope: "read",
summary: "身份修订历史",
params: IdParam,
response: z.object({ items: z.array(identity.IdentityRevision) }),
}),
},
// ── 文件 ──
mediaAssets: crud(
"/media-assets",
"资产",
{
row: assets.MediaAsset,
update: assets.MediaAssetUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() },
},
R,
),
assetUnitCoverage: crud(
"/asset-unit-coverage",
"资产覆盖",
{
row: assets.AssetUnitCoverage,
create: assets.AssetUnitCoverageCreate,
update: assets.AssetUnitCoverageUpdate,
filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() },
},
R,
),
assetLocations: crud(
"/asset-locations",
"资产位置",
{
row: assets.AssetLocation,
update: assets.AssetLocationUpdate,
filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() },
},
R,
),
pathBindings: crud(
"/path-bindings",
"路径绑定",
{
row: assets.PathBinding,
create: assets.PathBindingCreate,
update: assets.PathBindingUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() },
},
R,
),
// ── 实例 ──
libraryRoots: crud(
"/library-roots",
"媒体库根",
{
row: instances.LibraryRoot,
create: instances.LibraryRootCreate,
update: instances.LibraryRootUpdate,
},
R,
),
downloaderInstances: {
...crud(
"/downloader-instances",
"下载器实例",
{
row: instances.DownloaderInstance,
create: instances.DownloaderInstanceCreate,
update: instances.DownloaderInstanceUpdate,
},
R,
),
test: ep({
method: "post",
path: "/downloader-instances/{id}/test",
scope: "admin",
summary: "测试下载器连接",
params: IdParam,
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
}),
},
mediaServerInstances: crud(
"/media-server-instances",
"媒体服务器实例",
{
row: instances.MediaServerInstance,
create: instances.MediaServerInstanceCreate,
update: instances.MediaServerInstanceUpdate,
},
R,
),
pathMappings: {
...crud(
"/path-mappings",
"路径映射",
{
row: instances.PathMapping,
create: instances.PathMappingCreate,
update: instances.PathMappingUpdate,
},
R,
),
verify: ep({
method: "post",
path: "/path-mappings/{id}/verify",
scope: "admin",
summary: "校验路径映射可读写与挂载健康",
params: IdParam,
response: instances.PathMapping,
}),
remove: del("/path-mappings/{id}", "删除路径映射"),
},
// ── 来源 ──
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
siteAccounts: crud(
"/site-accounts",
"站点账号",
{
row: sources.SiteAccount,
create: sources.SiteAccountCreate,
update: sources.SiteAccountUpdate,
filters: { siteId: Id.optional() },
},
R,
),
feedSources: {
...crud(
"/feed-sources",
"Feed",
{
row: sources.FeedSource,
create: sources.FeedSourceCreate,
update: sources.FeedSourceUpdate,
filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() },
},
R,
),
fetchNow: ep({
method: "post",
path: "/feed-sources/{id}/fetch",
scope: "admin",
summary: "立即抓取(仍受站点限流)",
params: IdParam,
response: z.object({ taskId: Id }),
}),
},
sourceReleases: crud(
"/source-releases",
"站点资源",
{
row: sources.SourceRelease,
filters: {
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: sources.ResolutionStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
observations: crud(
"/observations",
"观察",
{
row: sources.Observation,
filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() },
},
R,
),
// ── 规则 ──
subscriptions: crud(
"/subscriptions",
"订阅",
{
row: policy.Subscription,
create: policy.SubscriptionCreate,
update: policy.SubscriptionUpdate,
filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional() },
},
R,
),
policyRevisions: {
...crud(
"/policy-revisions",
"策略版本",
{
row: policy.PolicyRevision,
create: policy.PolicyRevisionCreate,
update: policy.PolicyRevisionUpdate,
filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() },
},
R,
),
preview: ep({
method: "post",
path: "/policy-revisions/{id}/preview",
scope: "read",
summary: "历史回放预览",
params: IdParam,
response: policy.PolicyRevision,
}),
publish: ep({
method: "post",
path: "/policy-revisions/{id}/publish",
scope: "admin",
summary: "发布策略版本",
params: IdParam,
body: z.object({ expectedRevision: z.number().int().min(1) }),
response: policy.PolicyRevision,
}),
rollback: ep({
method: "post",
path: "/policy-revisions/{id}/rollback",
scope: "admin",
summary: "回滚到该版本",
params: IdParam,
response: policy.PolicyRevision,
}),
},
acquisitionSlots: crud(
"/acquisition-slots",
"收录目标",
{
row: policy.AcquisitionSlot,
create: policy.AcquisitionSlotCreate,
update: policy.AcquisitionSlotUpdate,
filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional() },
},
R,
),
preferenceTags: {
...crud(
"/preference-tags",
"偏好标签",
{
row: policy.PreferenceTag,
create: policy.PreferenceTagCreate,
update: policy.PreferenceTagUpdate,
filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() },
},
R,
),
/** 删除 = 标记 deleted + 写抑制记录 */
remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"),
},
preferenceSuppressions: crud(
"/preference-suppressions",
"偏好抑制",
{
row: policy.PreferenceSuppression,
create: policy.PreferenceSuppressionCreate,
update: policy.PreferenceSuppressionUpdate,
filters: { active: z.enum(["true", "false"]).optional() },
},
R,
),
// ── 执行 ──
tasks: {
...crud(
"/tasks",
"任务",
{
row: execution.Task,
filters: {
status: execution.TaskStatus.optional(),
kind: execution.TaskKind.optional(),
subjectId: Id.optional(),
},
},
R,
),
cancel: ep({
method: "post",
path: "/tasks/{id}/cancel",
scope: "organize.execute",
summary: "取消任务(停止未来步骤)",
params: IdParam,
body: execution.TaskCancelRequest,
response: execution.Task,
}),
retry: ep({
method: "post",
path: "/tasks/{id}/retry",
scope: "organize.execute",
summary: "重试失败任务",
params: IdParam,
response: execution.Task,
}),
operations: ep({
method: "get",
path: "/tasks/{id}/operations",
scope: "read",
summary: "任务的操作账本(业务时间线)",
params: IdParam,
response: z.object({ items: z.array(execution.AssetOperation) }),
}),
},
acquisitionPlans: crud(
"/acquisition-plans",
"下载计划",
{
row: execution.AcquisitionPlan,
filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() },
},
R,
),
downloadTasks: {
...crud(
"/download-tasks",
"下载任务",
{
row: execution.DownloadTask,
filters: {
instanceId: Id.optional(),
status: execution.DownloadStatus.optional(),
managed: z.enum(["true", "false"]).optional(),
},
},
R,
),
pause: ep({
method: "post",
path: "/download-tasks/{id}/pause",
scope: "organize.execute",
summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)",
params: IdParam,
response: execution.DownloadTask,
}),
resume: ep({
method: "post",
path: "/download-tasks/{id}/resume",
scope: "organize.execute",
summary: "恢复下载",
params: IdParam,
response: execution.DownloadTask,
}),
},
// ── 人工 / AI ──
decisions: {
...crud(
"/decisions",
"待处理事项",
{
row: agent.Decision,
filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() },
},
R,
),
resolve: ep({
method: "post",
path: "/decisions/{id}/resolve",
scope: "organize.execute",
summary: "处理待处理事项",
params: IdParam,
body: agent.DecisionResolveInput,
response: agent.Decision,
}),
},
approvals: crud(
"/approvals",
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
automationGrants: crud(
"/automation-grants",
"持续授权",
{
row: agent.AutomationGrant,
create: agent.AutomationGrantCreate,
update: agent.AutomationGrantUpdate,
filters: { status: agent.GrantStatus.optional() },
},
R,
),
agentRuns: {
...crud(
"/agent-runs",
"Agent 运行",
{
row: agent.AgentRun,
create: agent.AgentRunCreate,
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
cancel: ep({
method: "post",
path: "/agent-runs/{id}/cancel",
scope: "organize.execute",
summary: "取消 Agent 运行",
params: IdParam,
response: agent.AgentRun,
}),
message: ep({
method: "post",
path: "/agent-runs/{id}/messages",
scope: "organize.execute",
summary: "向运行中的会话追加用户消息(纠正/补充)",
params: IdParam,
body: z.object({ text: z.string().min(1).max(8000) }),
response: agent.AgentRun,
}),
},
artifacts: crud(
"/artifacts",
"产物",
{
row: agent.Artifact,
filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() },
},
R,
),
// ── 运维 ──
reconcileRuns: crud(
"/reconcile-runs",
"校验运行",
{
row: ops.ReconcileRun,
create: ops.ReconcileRunCreate,
filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() },
},
R,
),
serverMappings: crud(
"/server-mappings",
"库存映射",
{
row: ops.ServerMapping,
update: ops.ServerMappingUpdate,
filters: {
mediaServerInstanceId: Id.optional(),
mediaEntityId: Id.optional(),
matchStatus: ops.ServerMatchStatus.optional(),
},
},
R,
),
apiKeys: {
list: ep({
method: "get",
path: "/api-keys",
scope: "admin",
summary: "API Key 列表",
query: CursorQuery,
response: cursorPage(ops.ApiKey),
}),
create: ep({
method: "post",
path: "/api-keys",
scope: "admin",
summary: "新建 API Key(明文只返回一次)",
body: ops.ApiKeyCreate,
response: ops.ApiKeyCreated,
}),
update: ep({
method: "patch",
path: "/api-keys/{id}",
scope: "admin",
summary: "修改/撤销 API Key",
params: IdParam,
body: ops.ApiKeyUpdate,
response: ops.ApiKey,
}),
},
auditEvents: {
list: ep({
method: "get",
path: "/audit-events",
scope: "admin",
summary: "审计事件",
query: listQuery({
targetType: z.string().optional(),
targetId: z.string().optional(),
action: z.string().optional(),
}),
response: cursorPage(ops.AuditEvent),
}),
},
secrets: {
list: ep({
method: "get",
path: "/secrets",
scope: "admin",
summary: "凭证元数据列表",
query: CursorQuery,
response: cursorPage(ops.Secret),
}),
create: ep({
method: "post",
path: "/secrets",
scope: "admin",
summary: "新建凭证(只写)",
body: ops.SecretCreate,
response: ops.Secret,
}),
update: ep({
method: "patch",
path: "/secrets/{id}",
scope: "admin",
summary: "轮换凭证",
params: IdParam,
body: ops.SecretUpdate,
response: ops.Secret,
}),
},
// ── 整理(公开合同) ──
organization: {
list: ep({
method: "get",
path: "/organization-records",
scope: "organize.read",
summary: "整理记录列表",
query: organization.OrganizationListQuery,
response: cursorPage(organization.PublicOrganizationRecord),
}),
get: ep({
method: "get",
path: "/organization-records/{id}",
scope: "organize.read",
summary: "整理记录详情(含逐文件结果)",
params: IdParam,
response: organization.PublicOrganizationRecord,
}),
changes: ep({
method: "get",
path: "/organization-records/changes",
scope: "organize.read",
summary: "按 changeSeq 增量读取(含既有记录状态更新)",
query: organization.OrganizationChangesQuery,
response: organization.OrganizationChangesResponse,
}),
create: ep({
method: "post",
path: "/organization-records",
scope: "organize.execute",
summary: "手动发起整理",
body: organization.OrganizeRequest,
response: organization.OrganizeAccepted,
}),
},
// ── 能力 ──
capabilities: {
list: ep({
method: "get",
path: "/capabilities",
scope: "read",
summary: "能力注册中心描述",
response: z.object({ items: z.array(CapabilityDescriptor) }),
}),
invoke: ep({
method: "post",
path: "/capabilities/{capabilityId}/invoke",
scope: "read",
summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)",
params: z.object({ capabilityId: KnownCapabilityId }),
body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }),
response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }),
}),
},
// ── 设置 ──
settings: {
get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }),
update: ep({
method: "patch",
path: "/settings",
scope: "admin",
summary: "修改全局设置",
body: GlobalSettingsUpdate,
response: GlobalSettings,
}),
},
} as const;
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
export function buildPath(path: string, params: Record<string, string> = {}): string {
const filled = path.replace(/\{(\w+)\}/g, (_, k: string) => {
const v = params[k];
if (v === undefined) throw new Error(`缺少路径参数 ${k}`);
return encodeURIComponent(v);
});
return `${API_PREFIX}${filled}`;
}
/** 扁平列出全部端点(生成 OpenAPI、权限表与测试用) */
export function listEndpoints(): EndpointDef[] {
return Object.values(Api).flatMap((group) => Object.values(group) as EndpointDef[]);
}
export type EndpointBody<E extends EndpointDef> = E["body"] extends z.ZodType ? z.input<E["body"]> : never;
export type EndpointResponse<E extends EndpointDef> = z.infer<E["response"]>;
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { Segment } from "./content";
// 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2)
// 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。
export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]);
export type AssetKind = z.infer<typeof AssetKind>;
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
export type AssetStatus = z.infer<typeof AssetStatus>;
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */
export const DeliveryTraits = z.object({
resolution: z.string().optional(),
videoCodec: z.string().optional(),
audioCodecs: z.array(z.string()).optional(),
hdr: z.array(z.string()).optional(),
sourceType: z.string().optional(),
releaseGroup: z.string().optional(),
subtitles: z.array(z.string()).optional(),
});
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
export const MediaAsset = EntityBase.extend({
mediaEntityId: Id.nullable(),
kind: AssetKind,
/** 附属文件(字幕/音轨)指向主视频资产 */
parentAssetId: Id.nullable(),
sizeBytes: NonNegInt.nullable(),
contentHash: z.string().nullable(),
hashAlgorithm: z.string().nullable(),
sampleDigest: z.string().nullable(),
/** ffprobe/MediaInfo 结果 */
mediaInfo: JsonObject,
delivery: DeliveryTraits,
/** 内容版本:院线版/导演剪辑版等 */
contentEdition: z.string().nullable(),
sourceReleaseId: Id.nullable(),
downloadTaskId: Id.nullable(),
status: AssetStatus,
verifiedAt: Timestamp.nullable(),
});
export type MediaAsset = z.infer<typeof MediaAsset>;
export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
parentAssetId: true,
sizeBytes: true,
contentHash: true,
hashAlgorithm: true,
sampleDigest: true,
mediaInfo: true,
delivery: true,
contentEdition: true,
sourceReleaseId: true,
downloadTaskId: true,
status: true,
verifiedAt: true,
});
export type MediaAssetCreate = z.infer<typeof MediaAssetCreate>;
export const MediaAssetUpdate = MediaAssetCreate.partial();
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
export type CoverageKind = z.infer<typeof CoverageKind>;
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
export const AssetUnitCoverage = EntityBase.extend({
mediaAssetId: Id,
contentUnitId: Id,
coverage: CoverageKind,
segment: Segment.nullable(),
evidence: JsonObject,
});
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
coverage: true,
segment: true,
evidence: true,
});
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
mediaAssetId: true,
contentUnitId: true,
}).partial();
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
export type LocationKind = z.infer<typeof LocationKind>;
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
export type LocationStatus = z.infer<typeof LocationStatus>;
export const AssetLocation = EntityBase.extend({
mediaAssetId: Id,
locationKind: LocationKind,
libraryRootId: Id.nullable(),
downloaderInstanceId: Id.nullable(),
/** 相对库根(或下载器保存根)的路径 */
relativePath: z.string().min(1),
status: LocationStatus,
isPrimary: z.boolean(),
sizeBytes: NonNegInt.nullable(),
inode: z.number().int().nullable(),
device: z.number().int().nullable(),
mtime: Timestamp.nullable(),
sidecarOfLocationId: Id.nullable(),
lastSeenAt: Timestamp.nullable(),
lastCheckedAt: Timestamp.nullable(),
checkDetail: JsonObject,
});
export type AssetLocation = z.infer<typeof AssetLocation>;
export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED)
.partial({
locationKind: true,
libraryRootId: true,
downloaderInstanceId: true,
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
sidecarOfLocationId: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
})
.refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, {
message: "library 位置必须指定 libraryRootId",
});
export type AssetLocationCreate = z.infer<typeof AssetLocationCreate>;
export const AssetLocationUpdate = AssetLocation.pick({
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
}).partial();
export type AssetLocationUpdate = z.infer<typeof AssetLocationUpdate>;
export const BindingKind = z.enum(["entity", "season"]);
export type BindingKind = z.infer<typeof BindingKind>;
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
export type BindingStatus = z.infer<typeof BindingStatus>;
export const PathBinding = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nonnegative().nullable(),
bindingKind: BindingKind,
libraryRootId: Id,
relativePath: z.string().min(1),
templateVersion: z.string().nullable(),
status: BindingStatus,
boundAt: Timestamp,
boundBy: z.string().nullable(),
supersededById: Id.nullable(),
note: z.string().nullable(),
});
export type PathBinding = z.infer<typeof PathBinding>;
export const PathBindingCreate = PathBinding.pick({
mediaEntityId: true,
contentUnitId: true,
seasonNumber: true,
bindingKind: true,
libraryRootId: true,
relativePath: true,
templateVersion: true,
status: true,
note: true,
})
.partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true })
.refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), {
message: "season 绑定必须给 seasonNumber,entity 绑定不能给",
});
export type PathBindingCreate = z.infer<typeof PathBindingCreate>;
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
export const PathBindingUpdate = z.object({
status: BindingStatus.optional(),
supersededById: Id.nullable().optional(),
note: z.string().nullable().optional(),
});
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;
+218
View File
@@ -0,0 +1,218 @@
import { z } from "zod";
import { MediaAsset } from "./assets";
import type { KnownCapabilityId } from "./capability";
import { Id, JsonObject, NonNegInt } from "./common";
import { MediaEntity, MediaKind } from "./content";
import { AcquisitionPlan, DownloadTask, Task, TaskStatus } from "./execution";
import { ExternalEntityType, ExternalIdentity } from "./identity";
import { ServerMapping } from "./ops";
import { OrganizationAction, OrganizationRecord } from "./organization";
import { AcquisitionSlot, PolicyRevision } from "./policy";
import { SourceRelease } from "./sources";
// 首批能力的输入/输出 schema;名字与 CAPABILITY_CATALOG 中的 inputSchema/outputSchema 一致。
// 写能力的输入只接受计划/对象 ID,不接受模型自拟的幂等键或任意路径。
const Limit = z.number().int().min(1).max(100).default(20);
/** 写能力的通用受理结果:返回 task/plan ID,由任务页追踪(REQ §12.3) */
export const Accepted = z.object({ taskId: Id, planId: Id.nullable() });
export type Accepted = z.infer<typeof Accepted>;
export const CatalogLookupInput = z
.object({
query: z.string().min(1).max(200).optional(),
externalId: z.object({ provider: z.string(), entityType: ExternalEntityType, externalId: z.string() }).optional(),
kind: MediaKind.optional(),
year: z.number().int().optional(),
includeRemote: z.boolean().default(false),
limit: Limit,
})
.refine((v) => !!v.query || !!v.externalId, { message: "query 与 externalId 至少给一个" });
export const CatalogLookupOutput = z.object({
local: z.array(z.object({ entity: MediaEntity, score: z.number(), matchedAlias: z.string().nullable() })),
remote: z.array(
z.object({
provider: z.string(),
entityType: ExternalEntityType,
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
}),
),
});
export const ReleaseSearchInput = z.object({
query: z.string().min(1).max(200).optional(),
mediaEntityId: Id.optional(),
siteIds: z.array(Id).optional(),
/** 本地快照不足时是否实时补查 */
allowLiveSearch: z.boolean().default(true),
limit: Limit,
});
export const ReleaseSearchOutput = z.object({
items: z.array(SourceRelease),
/** 各站点快照新鲜度与是否实时补查 */
coverage: z.array(
z.object({ siteId: Id, fromSnapshot: z.boolean(), live: z.boolean(), error: z.string().nullable() }),
),
});
export const ReleaseInspectInput = z.object({ sourceReleaseId: Id, refreshDetail: z.boolean().default(false) });
export const ReleaseInspectOutput = z.object({ release: SourceRelease, inventory: z.array(MediaAsset) });
export const IdentityResolveInput = z
.object({
observationId: Id.optional(),
sourceReleaseId: Id.optional(),
candidate: z
.object({
mediaEntityId: Id.optional(),
provider: z.string().optional(),
entityType: ExternalEntityType.optional(),
externalId: z.string().optional(),
})
.optional(),
evidenceIds: z.array(Id).default([]),
})
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" });
export const IdentityResolveOutput = z.object({
status: z.enum(["resolved", "proposed", "ambiguous", "unidentifiable"]),
mediaEntityId: Id.nullable(),
identities: z.array(ExternalIdentity),
decisionId: Id.nullable(),
});
export const DownloadPlanInput = z.object({ slotId: Id, sourceReleaseId: Id });
export const DownloadPlanOutput = z.object({
plan: AcquisitionPlan.nullable(),
slot: AcquisitionSlot,
/** 硬条件不满足或预算/阈值阻止时的原因 */
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
requiresApproval: z.boolean(),
});
export const DownloadSubmitInput = z.object({ planId: Id, approvalId: Id.optional() });
export const DownloadSubmitOutput = Accepted.extend({ downloadTask: DownloadTask.nullable() });
export const OrganizePlanInput = z
.object({
downloadTaskId: Id.optional(),
mediaAssetIds: z.array(Id).optional(),
action: OrganizationAction.default("copy"),
targetLibraryRootId: Id.optional(),
})
.refine((v) => !!v.downloadTaskId || !!v.mediaAssetIds?.length, { message: "需要 downloadTaskId 或 mediaAssetIds" });
export const OrganizePlanOutput = z.object({
planId: Id,
payloadHash: z.string(),
files: z.array(
z.object({
sourcePath: z.string(),
targetPath: z.string().nullable(),
action: OrganizationAction,
skipReason: z.string().nullable(),
}),
),
conflicts: z.array(z.object({ code: z.string(), message: z.string(), path: z.string().nullable() })),
requiresApproval: z.boolean(),
});
export const OrganizeExecuteInput = z.object({ planId: Id, payloadHash: z.string().min(1), approvalId: Id.optional() });
export const OrganizeExecuteOutput = Accepted.extend({ record: OrganizationRecord.nullable() });
export const TaskInspectInput = z.object({ taskId: Id });
export const TaskInspectOutput = z.object({
task: Task,
children: z.array(Task),
timeline: z.array(z.object({ at: z.string(), type: z.string(), summary: z.string(), data: JsonObject.optional() })),
});
export const TaskRetryInput = z.object({ taskId: Id, reason: z.string().max(500).optional() });
export const TaskRetryOutput = z.object({ taskId: Id, status: TaskStatus });
export const TaskCancelInput = z.object({ taskId: Id, reason: z.string().min(1).max(500) });
export const TaskCancelOutput = z.object({ taskId: Id, status: TaskStatus, cancelRequested: z.boolean() });
export const PolicyPreviewInput = z.object({
policyRevisionId: Id,
sampleLimit: z.number().int().min(1).max(1000).default(200),
});
export const PolicyPreviewOutput = z.object({
newlyMatched: NonNegInt,
newlyExcluded: NonNegInt,
samples: z.array(z.object({ sourceReleaseId: Id, before: z.string(), after: z.string(), reason: z.string() })),
});
export const PolicyApplyInput = z.object({
policyRevisionId: Id,
expectedRevision: z.number().int().min(1),
approvalId: Id.optional(),
});
export const PolicyApplyOutput = z.object({ policy: PolicyRevision });
export const InventoryQueryInput = z.object({
mediaEntityId: Id.optional(),
query: z.string().max(200).optional(),
includeServerInventory: z.boolean().default(false),
limit: Limit,
});
export const InventoryQueryOutput = z.object({
items: z.array(
z.object({
entity: MediaEntity,
assets: z.array(MediaAsset),
slots: z.array(AcquisitionSlot),
identities: z.array(ExternalIdentity.pick({ provider: true, entityType: true, externalId: true, status: true })),
/** 外部库存证据;"未知 ≠ 缺失" */
server: z.array(ServerMapping),
}),
),
});
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
"release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput },
"identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput },
"download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput },
"download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput },
"organize.plan": { input: OrganizePlanInput, output: OrganizePlanOutput },
"organize.execute": { input: OrganizeExecuteInput, output: OrganizeExecuteOutput },
"task.inspect": { input: TaskInspectInput, output: TaskInspectOutput },
"task.retry": { input: TaskRetryInput, output: TaskRetryOutput },
"task.cancel": { input: TaskCancelInput, output: TaskCancelOutput },
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
export type CatalogLookupInput = z.infer<typeof CatalogLookupInput>;
export type CatalogLookupOutput = z.infer<typeof CatalogLookupOutput>;
export type ReleaseSearchInput = z.infer<typeof ReleaseSearchInput>;
export type ReleaseSearchOutput = z.infer<typeof ReleaseSearchOutput>;
export type ReleaseInspectInput = z.infer<typeof ReleaseInspectInput>;
export type ReleaseInspectOutput = z.infer<typeof ReleaseInspectOutput>;
export type IdentityResolveInput = z.infer<typeof IdentityResolveInput>;
export type IdentityResolveOutput = z.infer<typeof IdentityResolveOutput>;
export type DownloadPlanInput = z.infer<typeof DownloadPlanInput>;
export type DownloadPlanOutput = z.infer<typeof DownloadPlanOutput>;
export type DownloadSubmitInput = z.infer<typeof DownloadSubmitInput>;
export type DownloadSubmitOutput = z.infer<typeof DownloadSubmitOutput>;
export type OrganizePlanInput = z.infer<typeof OrganizePlanInput>;
export type OrganizePlanOutput = z.infer<typeof OrganizePlanOutput>;
export type OrganizeExecuteInput = z.infer<typeof OrganizeExecuteInput>;
export type OrganizeExecuteOutput = z.infer<typeof OrganizeExecuteOutput>;
export type TaskInspectInput = z.infer<typeof TaskInspectInput>;
export type TaskInspectOutput = z.infer<typeof TaskInspectOutput>;
export type TaskRetryInput = z.infer<typeof TaskRetryInput>;
export type TaskRetryOutput = z.infer<typeof TaskRetryOutput>;
export type TaskCancelInput = z.infer<typeof TaskCancelInput>;
export type TaskCancelOutput = z.infer<typeof TaskCancelOutput>;
export type PolicyPreviewInput = z.infer<typeof PolicyPreviewInput>;
export type PolicyPreviewOutput = z.infer<typeof PolicyPreviewOutput>;
export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
+235
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { ApiScope } from "./common";
/**
* 能力调用上下文(REQ §10.3)。产品自有接口,不是 Pi SDK 原生 API。
@@ -43,3 +44,237 @@ export interface CapabilityDefinition<I extends z.ZodType = z.ZodType, O extends
idempotent: boolean;
supportsPreview: boolean;
}
// ── 能力描述符与首批能力(任务书 §3.1-9;REQ §10.3) ──
/** 风险级别:low 只读;medium 预览/可撤回写入;high 触发下载、文件变更或策略发布 */
export const RiskLevel = z.enum(["low", "medium", "high"]);
export type RiskLevel = z.infer<typeof RiskLevel>;
/** 接口类别(REQ §12.3):read / preview / propose / approve / execute */
export const CapabilityKind = z.enum(["read", "preview", "propose", "approve", "execute"]);
export type CapabilityKind = z.infer<typeof CapabilityKind>;
/** 首批能力 ID */
export const CAPABILITY_IDS = [
"catalog.lookup",
"release.search",
"release.inspect",
"identity.resolve",
"download.plan",
"download.submit",
"organize.plan",
"organize.execute",
"task.inspect",
"task.retry",
"task.cancel",
"policy.preview",
"policy.apply",
"inventory.query",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
/** schema 引用:指向 contracts 中导出的 schema 名(如 "ReleaseSearchInput"),便于序列化给 UI/MCP 列表 */
export const SchemaRef = z.string().regex(/^[A-Z][A-Za-z0-9]*$/);
/** 能力注册中心的可序列化描述(处理器在 server 侧挂载;模型只能看到白名单内的描述) */
export const CapabilityDescriptor = z.object({
id: CapabilityId,
version: z.number().int().min(1),
title: z.string().min(1),
description: z.string().min(1),
kind: CapabilityKind,
inputSchema: SchemaRef,
outputSchema: SchemaRef,
/** 调用所需 API scope */
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: z.boolean(),
supportsPreview: z.boolean(),
});
export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
/** 首批能力的描述(inputSchema/outputSchema 名对应 capabilities-io.ts 中的导出) */
export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor> = {
"catalog.lookup": d(
"catalog.lookup",
"查作品",
"按标题/别名/外部 ID 查本地作品与元数据候选",
"read",
"CatalogLookup",
"read",
"low",
true,
false,
),
"release.search": d(
"release.search",
"搜资源",
"先查本地资源索引与站点快照,不足时对已开启站点实时补查",
"read",
"ReleaseSearch",
"read",
"low",
true,
false,
),
"release.inspect": d(
"release.inspect",
"看资源详情",
"读取站点资源详情、文件清单、促销与解析结果",
"read",
"ReleaseInspect",
"read",
"low",
true,
false,
),
"identity.resolve": d(
"identity.resolve",
"识别身份",
"为观察/资源提出身份映射;高证据自动接受,歧义进入待处理",
"propose",
"IdentityResolve",
"organize.execute",
"medium",
true,
true,
),
"download.plan": d(
"download.plan",
"生成下载计划",
"评估候选并生成带 Slot/预算预留的下载计划(不提交)",
"preview",
"DownloadPlan",
"organize.execute",
"medium",
true,
true,
),
"download.submit": d(
"download.submit",
"提交下载",
"按已保存的计划提交到下载器;校验授权与硬阈值",
"execute",
"DownloadSubmit",
"organize.execute",
"high",
true,
false,
),
"organize.plan": d(
"organize.plan",
"生成整理计划",
"冻结源/目标路径、操作类型与前置条件",
"preview",
"OrganizePlan",
"organize.read",
"low",
true,
true,
),
"organize.execute": d(
"organize.execute",
"执行整理",
"执行已保存的整理计划(Copy/Move),生成整理记录",
"execute",
"OrganizeExecute",
"organize.execute",
"high",
true,
false,
),
"task.inspect": d(
"task.inspect",
"查看任务",
"读取任务状态、业务时间线与错误",
"read",
"TaskInspect",
"read",
"low",
true,
false,
),
"task.retry": d(
"task.retry",
"重试任务",
"对失败/死信任务重新排队;副作用经操作账本去重",
"execute",
"TaskRetry",
"organize.execute",
"medium",
true,
false,
),
"task.cancel": d(
"task.cancel",
"取消任务",
"停止未来步骤,不撤销已完成的外部动作",
"execute",
"TaskCancel",
"organize.execute",
"medium",
true,
false,
),
"policy.preview": d(
"policy.preview",
"预览策略",
"用历史数据回放策略草案,展示新增命中与误排除",
"preview",
"PolicyPreview",
"read",
"low",
true,
true,
),
"policy.apply": d(
"policy.apply",
"发布策略",
"发布策略版本(需授权);不直接执行 JavaScript",
"execute",
"PolicyApply",
"admin",
"high",
true,
true,
),
"inventory.query": d(
"inventory.query",
"查库存",
"查询本地资产、Slot 满足度与外部库存证据",
"read",
"InventoryQuery",
"read",
"low",
true,
false,
),
};
function d(
id: KnownCapabilityId,
title: string,
description: string,
kind: CapabilityKind,
schemaBase: string,
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: boolean,
supportsPreview: boolean,
): CapabilityDescriptor {
return {
id,
version: 1,
title,
description,
kind,
inputSchema: `${schemaBase}Input`,
outputSchema: `${schemaBase}Output`,
scope,
riskLevel,
idempotent,
supportsPreview,
};
}
+29
View File
@@ -91,3 +91,32 @@ export const HealthStatus = z.object({
time: Timestamp,
});
export type HealthStatus = z.infer<typeof HealthStatus>;
/** 日期(无时区),如 air_date:YYYY-MM-DD */
export const DateOnly = z.iso.date();
export type DateOnly = z.infer<typeof DateOnly>;
/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */
export const JsonObject = z.record(z.string(), z.unknown());
export type JsonObject = z.infer<typeof JsonObject>;
/** 字节数、计数等非负整数(DB 为 bigint,客户端按 number 解析,上限 2^53) */
export const NonNegInt = z.number().int().nonnegative();
/** 操作者类型:与 DB 中 actor_type 列一致 */
export const ActorType = z.enum(["user", "agent", "system", "api_key"]);
export type ActorType = z.infer<typeof ActorType>;
/** 实体行的公共字段 */
export const EntityBase = z.object({
id: Id,
createdAt: Timestamp,
updatedAt: Timestamp,
});
/** 所有实体 Create 输入都要剔除的服务端字段 */
export const SERVER_MANAGED = { id: true, createdAt: true, updatedAt: true } as const;
/** 路径参数 `{id}` */
export const IdParam = z.object({ id: Id });
export type IdParam = z.infer<typeof IdParam>;
+129
View File
@@ -0,0 +1,129 @@
import { z } from "zod";
import { DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
export type MediaKind = z.infer<typeof MediaKind>;
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
export const MediaEntity = EntityBase.extend({
kind: MediaKind,
title: z.string().min(1),
originalTitle: z.string().nullable(),
year: z.number().int().min(1870).max(2200).nullable(),
status: MediaEntityStatus,
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
mergedIntoId: Id.nullable(),
/** 最终展示字段(每个字段带来源/语言/时间) */
metadata: JsonObject,
/** 人工锁定的字段名;刷新不得覆盖 */
fieldLocks: z.array(z.string()),
lastMetadataRefreshAt: Timestamp.nullable(),
});
export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
kind: true,
title: true,
originalTitle: true,
year: true,
status: true,
metadata: true,
fieldLocks: true,
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
/** 合并通过专门动作完成,Update 不接受 status=merged */
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
status: MediaEntityStatus.exclude(["merged"]).optional(),
});
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
/** 综艺内容类型(REQ §5.1) */
export const ContentType = z.enum([
"main",
"extra",
"behind_the_scenes",
"performance_only",
"preview",
"reaction",
"unknown",
]);
export type ContentType = z.infer<typeof ContentType>;
export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
export type Segment = z.infer<typeof Segment>;
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
export const ContentUnit = EntityBase.extend({
mediaEntityId: Id,
parentUnitId: Id.nullable(),
kind: ContentUnitKind,
contentType: ContentType,
segment: Segment,
seasonNumber: z.number().int().nonnegative().nullable(),
episodeNumber: z.number().int().nonnegative().nullable(),
/** 来源期号原文,如 "第十期"、"20260927" */
sourceIssue: z.string().nullable(),
airDate: DateOnly.nullable(),
displayOrder: z.number().nullable(),
title: z.string().nullable(),
status: ContentUnitStatus,
/** 人工确认的编号锁定;刷新不得改编号 */
numberingLocked: z.boolean(),
metadata: JsonObject,
fieldLocks: z.array(z.string()),
});
export type ContentUnit = z.infer<typeof ContentUnit>;
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
parentUnitId: true,
contentType: true,
segment: true,
seasonNumber: true,
episodeNumber: true,
sourceIssue: true,
airDate: true,
displayOrder: true,
title: true,
status: true,
numberingLocked: true,
metadata: true,
fieldLocks: true,
});
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
export type AliasKind = z.infer<typeof AliasKind>;
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
export type AliasSource = z.infer<typeof AliasSource>;
export const TitleAlias = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
alias: z.string().min(1),
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
normalized: z.string().min(1),
language: z.string().nullable(),
kind: AliasKind,
source: AliasSource,
locked: z.boolean(),
});
export type TitleAlias = z.infer<typeof TitleAlias>;
/** normalized 由服务端计算,不接受客户端传入 */
export const TitleAliasCreate = TitleAlias.pick({
mediaEntityId: true,
contentUnitId: true,
alias: true,
language: true,
kind: true,
source: true,
locked: true,
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
+166 -2
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { Timestamp } from "./common";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent";
import { Id, NonNegInt, Timestamp } from "./common";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution";
import { OrganizationStatus } from "./organization";
import { FeedHealth } from "./sources";
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
export const EventEnvelope = z.object({
@@ -15,10 +19,30 @@ export type EventEnvelope<P = unknown> = {
payload: P;
};
/** 已知事件类型;业务事件后续按 `域.动作` 追加 */
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
export const EventType = {
heartbeat: "system.heartbeat",
resync: "system.resync",
taskCreated: "task.created",
taskStatusChanged: "task.status_changed",
downloadSubmitted: "download.submitted",
downloadProgress: "download.progress",
downloadStatusChanged: "download.status_changed",
downloadThreshold: "download.threshold",
organizationChanged: "organization.changed",
agentRunStatus: "agent.run_status",
agentTextDelta: "agent.text_delta",
agentToolCall: "agent.tool_call",
feedFetched: "feed.fetched",
feedHealthChanged: "feed.health_changed",
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -26,3 +50,143 @@ export type EventType = (typeof EventType)[keyof typeof EventType];
export const HeartbeatPayload = z.object({
role: z.string(),
});
/** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */
export const ResyncPayload = z.object({ reason: z.string() });
export const TaskCreatedPayload = z.object({
taskId: Id,
kind: TaskKind,
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
});
export const TaskStatusChangedPayload = z.object({
taskId: Id,
kind: TaskKind,
from: TaskStatus.nullable(),
to: TaskStatus,
error: z.string().nullable(),
});
export const DownloadSubmittedPayload = z.object({
downloadTaskId: Id,
instanceId: Id,
torrentHash: z.string(),
planId: Id.nullable(),
name: z.string(),
});
export const DownloadProgressPayload = z.object({
downloadTaskId: Id,
progress: z.number().min(0).max(1),
downloadedBytes: NonNegInt,
sizeBytes: NonNegInt.nullable(),
});
export const DownloadStatusChangedPayload = z.object({
downloadTaskId: Id,
from: DownloadStatus.nullable(),
to: DownloadStatus,
pauseReason: PauseReason.nullable(),
});
/** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */
export const DownloadThresholdPayload = z.object({
volumeKey: z.string(),
state: z.enum(["tripped", "recovered", "unknown"]),
usedPercent: z.number().min(0).max(100).nullable(),
pausedTaskIds: z.array(Id),
});
/** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */
export const OrganizationChangedPayload = z.object({
recordId: Id,
status: OrganizationStatus,
changeSeq: z.string().regex(/^\d+$/),
mediaEntityId: Id.nullable(),
});
export const AgentRunStatusPayload = z.object({
runId: Id,
status: AgentRunStatus,
summary: z.string().nullable(),
});
/** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */
export const AgentTextDeltaPayload = z.object({
runId: Id,
runSeq: NonNegInt,
delta: z.string(),
});
export const AgentToolCallPayload = z.object({
runId: Id,
runSeq: NonNegInt,
capabilityId: z.string(),
phase: z.enum(["started", "succeeded", "failed", "denied"]),
/** 已脱敏的摘要,不含凭据/私有 URL */
summary: z.string(),
});
export const FeedFetchedPayload = z.object({
feedId: Id,
newItems: NonNegInt,
updatedItems: NonNegInt,
notModified: z.boolean(),
});
export const FeedHealthChangedPayload = z.object({
feedId: Id,
from: FeedHealth.nullable(),
to: FeedHealth,
reason: z.string().nullable(),
});
export const DecisionCreatedPayload = z.object({
decisionId: Id,
kind: DecisionKind,
severity: Severity,
title: z.string(),
});
export const DecisionUpdatedPayload = z.object({
decisionId: Id,
status: DecisionStatus,
});
/** 事件类型 → payload schema */
export const EVENT_PAYLOADS = {
"system.heartbeat": HeartbeatPayload,
"system.resync": ResyncPayload,
"task.created": TaskCreatedPayload,
"task.status_changed": TaskStatusChangedPayload,
"download.submitted": DownloadSubmittedPayload,
"download.progress": DownloadProgressPayload,
"download.status_changed": DownloadStatusChangedPayload,
"download.threshold": DownloadThresholdPayload,
"organization.changed": OrganizationChangedPayload,
"agent.run_status": AgentRunStatusPayload,
"agent.text_delta": AgentTextDeltaPayload,
"agent.tool_call": AgentToolCallPayload,
"feed.fetched": FeedFetchedPayload,
"feed.health_changed": FeedHealthChangedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
/** 已知事件的判别联合:`{ type, payload }` */
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[EventType];
/** 已知事件信封的 schema(判别联合) */
export const TypedEventEnvelope = z.discriminatedUnion(
"type",
Object.entries(EVENT_PAYLOADS).map(([type, payload]) =>
z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }),
) as unknown as [z.ZodObject, ...z.ZodObject[]],
);
export function isEventType(type: string): type is EventType {
return Object.hasOwn(EVENT_PAYLOADS, type);
}
/** 校验一个事件 payload;不合法返回 null */
export function parseEventPayload<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
const schema: z.ZodType = EVENT_PAYLOADS[type];
const r = schema.safeParse(payload);
return r.success ? (r.data as EventPayload<T>) : null;
}
+292
View File
@@ -0,0 +1,292 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { AcquisitionPriority } from "./policy";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
/** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */
export const TaskKind = z.enum([
"ingestFeed",
"resolveObservation",
"evaluateAcquisition",
"submitDownload",
"verifyAndImport",
"refreshMetadata",
"reconcileLibrary",
"cleanupDownloadCache",
"resolveDecision",
"agentRun",
]);
export type TaskKind = z.infer<typeof TaskKind>;
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
export type TaskStatus = z.infer<typeof TaskStatus>;
export const Task = EntityBase.extend({
kind: TaskKind,
handlerVersion: z.number().int().min(1),
idempotencyKey: z.string().min(1),
status: TaskStatus,
/** 数值越小越先执行 */
priority: z.number().int(),
input: JsonObject,
output: JsonObject.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
parentTaskId: Id.nullable(),
engineRunId: z.string().nullable(),
attempts: NonNegInt,
maxAttempts: z.number().int().min(1),
nextRunAt: Timestamp,
waitReason: z.string().nullable(),
cancelRequestedAt: Timestamp.nullable(),
cancelReason: z.string().nullable(),
lastError: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
triggeredBy: z.string().nullable(),
});
export type Task = z.infer<typeof Task>;
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
export const TaskCreate = Task.pick({
kind: true,
handlerVersion: true,
idempotencyKey: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
}).partial({
handlerVersion: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
});
export type TaskCreate = z.infer<typeof TaskCreate>;
export const TaskUpdate = Task.pick({
status: true,
output: true,
engineRunId: true,
attempts: true,
nextRunAt: true,
waitReason: true,
lastError: true,
startedAt: true,
finishedAt: true,
}).partial();
export type TaskUpdate = z.infer<typeof TaskUpdate>;
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
export type PlanPurpose = z.infer<typeof PlanPurpose>;
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
export type PlanStatus = z.infer<typeof PlanStatus>;
/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */
export const AcquisitionPlan = EntityBase.extend({
slotId: Id,
sourceReleaseId: Id,
taskId: Id.nullable(),
purpose: PlanPurpose,
status: PlanStatus,
priority: AcquisitionPriority,
/** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */
evaluation: JsonObject,
reservedBytes: NonNegInt,
budgetReservation: JsonObject,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(1),
idempotencyKey: z.string().min(1),
expiresAt: Timestamp.nullable(),
statusReason: z.string().nullable(),
});
export type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
taskId: true,
purpose: true,
priority: true,
evaluation: true,
reservedBytes: true,
budgetReservation: true,
expectedRevision: true,
expiresAt: true,
statusReason: true,
});
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
export const DownloadStatus = z.enum([
"queued",
"submitting",
"downloading",
"paused",
"stalled",
"completed",
"importing",
"imported",
"failed",
"removed",
"missing",
]);
export type DownloadStatus = z.infer<typeof DownloadStatus>;
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
export type PauseReason = z.infer<typeof PauseReason>;
export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/);
export const DownloadTask = EntityBase.extend({
instanceId: Id,
/** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */
torrentHash: TorrentHash,
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
acquisitionPlanId: Id.nullable(),
sourceReleaseId: Id.nullable(),
name: z.string(),
savePath: z.string().nullable(),
contentPath: z.string().nullable(),
category: z.string().nullable(),
tags: z.array(z.string()),
/** 是否在管理标签范围内;标签移除后暂停后续自动动作 */
managed: z.boolean(),
origin: DownloadOrigin,
status: DownloadStatus,
pauseReason: PauseReason.nullable(),
rawState: z.string().nullable(),
progress: z.number().min(0).max(1),
sizeBytes: NonNegInt.nullable(),
downloadedBytes: NonNegInt,
remainingBytes: NonNegInt.nullable(),
ratio: z.number().nullable(),
seedingTimeSec: NonNegInt.nullable(),
completedAt: Timestamp.nullable(),
lastProgressAt: Timestamp.nullable(),
/** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */
noProgressSince: Timestamp.nullable(),
lastSyncedAt: Timestamp.nullable(),
nextCheckAt: Timestamp.nullable(),
removedAt: Timestamp.nullable(),
fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(),
lastError: JsonObject.nullable(),
});
export type DownloadTask = z.infer<typeof DownloadTask>;
export const DownloadTaskCreate = DownloadTask.pick({
instanceId: true,
torrentHash: true,
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
name: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
}).partial({
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
});
export type DownloadTaskCreate = z.infer<typeof DownloadTaskCreate>;
export const DownloadTaskUpdate = DownloadTask.omit({
...SERVER_MANAGED,
instanceId: true,
torrentHash: true,
}).partial();
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
export type OperationPhase = z.infer<typeof OperationPhase>;
export const OperationKind = z.enum([
"copy",
"move",
"rename",
"mkdir",
"hardlink",
"delete_source",
"delete_target",
"verify",
"register",
"qb_pause",
"qb_resume",
"qb_remove",
"qb_set_tags",
"other",
]);
export type OperationKind = z.infer<typeof OperationKind>;
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
export type OperationStatus = z.infer<typeof OperationStatus>;
/** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */
export const AssetOperation = EntityBase.extend({
operationId: z.string().min(1),
taskId: Id.nullable(),
organizationRecordId: Id.nullable(),
planFingerprint: z.string().nullable(),
ordinal: z.number().int().nonnegative(),
phase: OperationPhase,
kind: OperationKind,
status: OperationStatus,
intent: JsonObject,
attemptToken: Id.nullable(),
attempts: NonNegInt,
result: JsonObject.nullable(),
error: JsonObject.nullable(),
mediaAssetId: Id.nullable(),
downloadTaskId: Id.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type AssetOperation = z.infer<typeof AssetOperation>;
export const AssetOperationCreate = AssetOperation.pick({
operationId: true,
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
phase: true,
kind: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
}).partial({
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
});
export type AssetOperationCreate = z.infer<typeof AssetOperationCreate>;
export const AssetOperationUpdate = AssetOperation.pick({
status: true,
attemptToken: true,
attempts: true,
result: true,
error: true,
mediaAssetId: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AssetOperationUpdate = z.infer<typeof AssetOperationUpdate>;
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common";
// 身份域:外部身份映射与修订(REQ §7.2)
// provider + entityType + externalId 对 accepted 唯一;TMDB movie:123 与 tv:123 是两个身份。
export const IdentityStatus = z.enum(["accepted", "proposed", "conflicted", "retired"]);
export type IdentityStatus = z.infer<typeof IdentityStatus>;
export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]);
export type ExternalEntityType = z.infer<typeof ExternalEntityType>;
/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */
export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]);
export type EvidenceLevel = z.infer<typeof EvidenceLevel>;
export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]);
export type IdentityVerifyStatus = z.infer<typeof IdentityVerifyStatus>;
export const IdentityProvider = z.string().regex(/^[a-z][a-z0-9_]*$/);
export const ExternalIdentity = EntityBase.extend({
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
provider: IdentityProvider,
entityType: ExternalEntityType,
externalId: z.string().min(1),
status: IdentityStatus,
evidenceLevel: EvidenceLevel,
evidence: JsonObject,
/** 人工锁:自动匹配/刷新不得改动 */
locked: z.boolean(),
revision: z.number().int().min(1),
validFrom: Timestamp.nullable(),
validUntil: Timestamp.nullable(),
verifiedAt: Timestamp.nullable(),
/** 404/超时/鉴权失败只记录到这里,不是删除映射的依据 */
verifyStatus: IdentityVerifyStatus,
/** 附属字段,如 TMDB episode_group */
extra: JsonObject,
});
export type ExternalIdentity = z.infer<typeof ExternalIdentity>;
const oneTarget = (v: { mediaEntityId?: string | null; contentUnitId?: string | null }) =>
Number(!!v.mediaEntityId) + Number(!!v.contentUnitId) === 1;
export const ExternalIdentityCreate = ExternalIdentity.pick({
mediaEntityId: true,
contentUnitId: true,
provider: true,
entityType: true,
externalId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.partial({
mediaEntityId: true,
contentUnitId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.refine(oneTarget, { message: "mediaEntityId 与 contentUnitId 必须且只能给一个" });
export type ExternalIdentityCreate = z.infer<typeof ExternalIdentityCreate>;
/** 变更一律生成 identity_revision;expectedRevision 用于乐观并发 */
export const ExternalIdentityUpdate = z.object({
expectedRevision: z.number().int().min(1),
status: IdentityStatus.optional(),
evidenceLevel: EvidenceLevel.optional(),
evidence: JsonObject.optional(),
locked: z.boolean().optional(),
validUntil: Timestamp.nullable().optional(),
extra: JsonObject.optional(),
reason: z.string().max(2000).optional(),
});
export type ExternalIdentityUpdate = z.infer<typeof ExternalIdentityUpdate>;
export const IdentityRevisionAction = z.enum([
"created",
"status_changed",
"relinked",
"evidence_updated",
"locked",
"unlocked",
]);
export type IdentityRevisionAction = z.infer<typeof IdentityRevisionAction>;
/** 身份修订历史:只追加,不修改 */
export const IdentityRevision = EntityBase.extend({
externalIdentityId: Id,
revision: z.number().int().min(1),
action: IdentityRevisionAction,
fromStatus: IdentityStatus.nullable(),
toStatus: IdentityStatus.nullable(),
before: JsonObject.nullable(),
after: JsonObject,
actorType: ActorType,
actorId: z.string().nullable(),
reason: z.string().nullable(),
});
export type IdentityRevision = z.infer<typeof IdentityRevision>;
/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */
export const IdentityRevisionCreate = IdentityRevision.pick({
externalIdentityId: true,
revision: true,
action: true,
fromStatus: true,
toStatus: true,
before: true,
after: true,
actorType: true,
actorId: true,
reason: true,
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
+13
View File
@@ -1,3 +1,16 @@
export * from "./agent";
export * from "./api";
export * from "./assets";
export * from "./capabilities-io";
export * from "./capability";
export * from "./common";
export * from "./content";
export * from "./events";
export * from "./execution";
export * from "./identity";
export * from "./instances";
export * from "./ops";
export * from "./organization";
export * from "./policy";
export * from "./settings";
export * from "./sources";
+161
View File
@@ -0,0 +1,161 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
export type InstanceHealth = z.infer<typeof InstanceHealth>;
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
export const ManagementMode = z.enum(["register_only", "managed"]);
export type ManagementMode = z.infer<typeof ManagementMode>;
export const LibraryRoot = EntityBase.extend({
name: z.string().min(1),
mediaKind: LibraryMediaKind,
/** 执行进程可访问的绝对路径 */
localPath: z.string().startsWith("/"),
managementMode: ManagementMode,
enabled: z.boolean(),
sentinelFile: z.string().nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
notes: z.string().nullable(),
});
export type LibraryRoot = z.infer<typeof LibraryRoot>;
export const LibraryRootCreate = LibraryRoot.pick({
name: true,
mediaKind: true,
localPath: true,
managementMode: true,
enabled: true,
sentinelFile: true,
notes: true,
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
export const LibraryRootUpdate = LibraryRootCreate.partial();
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
/** 只写凭证:明文仅在请求中出现,绝不回显 */
const Credential = z.string().min(1).max(4096);
export const DownloaderKind = z.enum(["qbittorrent"]);
export type DownloaderKind = z.infer<typeof DownloaderKind>;
export const DownloaderInstance = EntityBase.extend({
name: z.string().min(1),
kind: DownloaderKind,
baseUrl: z.url(),
username: z.string().nullable(),
secretRef: Id.nullable(),
/** 管理标签名(REQ §8.4) */
managedTag: z.string().min(1),
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
cacheVolumeKey: z.string().nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
export const DownloaderInstanceCreate = DownloaderInstance.pick({
name: true,
kind: true,
baseUrl: true,
username: true,
managedTag: true,
cacheVolumeKey: true,
enabled: true,
settings: true,
})
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
export type MediaServerKind = z.infer<typeof MediaServerKind>;
export const MediaServerInstance = EntityBase.extend({
name: z.string().min(1),
kind: MediaServerKind,
baseUrl: z.url(),
userId: z.string().nullable(),
secretRef: Id.nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
export const MediaServerInstanceCreate = MediaServerInstance.pick({
name: true,
kind: true,
baseUrl: true,
userId: true,
enabled: true,
settings: true,
})
.partial({ kind: true, userId: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
export const PathViewKind = z.enum(["downloader", "media_server"]);
export type PathViewKind = z.infer<typeof PathViewKind>;
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
export const PathMapping = EntityBase.extend({
viewKind: PathViewKind,
downloaderInstanceId: Id.nullable(),
mediaServerInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
remotePrefix: z.string().min(1),
localPrefix: z.string().startsWith("/"),
priority: z.number().int(),
enabled: z.boolean(),
verifyStatus: PathMappingVerifyStatus,
verifiedAt: Timestamp.nullable(),
verifyDetail: JsonObject,
});
export type PathMapping = z.infer<typeof PathMapping>;
const pathMappingOwnerOk = (v: {
viewKind?: PathViewKind;
downloaderInstanceId?: string | null;
mediaServerInstanceId?: string | null;
}) =>
v.viewKind === undefined ||
(v.viewKind === "downloader"
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
const PathMappingInput = PathMapping.omit({
...SERVER_MANAGED,
verifyStatus: true,
verifiedAt: true,
verifyDetail: true,
});
export const PathMappingCreate = PathMappingInput.partial({
downloaderInstanceId: true,
mediaServerInstanceId: true,
libraryRootId: true,
priority: true,
enabled: true,
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
message: "viewKind 与实例 ID 不匹配",
});
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
+194
View File
@@ -0,0 +1,194 @@
import { z } from "zod";
import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2)
export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]);
export type ReconcileKind = z.infer<typeof ReconcileKind>;
/** read_only:恢复后默认只读对账模式(REQ §8.5) */
export const ReconcileMode = z.enum(["normal", "read_only"]);
export type ReconcileMode = z.infer<typeof ReconcileMode>;
export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type ReconcileStatus = z.infer<typeof ReconcileStatus>;
export const ReconcileRun = EntityBase.extend({
kind: ReconcileKind,
mode: ReconcileMode,
status: ReconcileStatus,
scope: JsonObject,
/** 中断续扫位置 */
resumeCursor: JsonObject,
stats: JsonObject,
taskId: Id.nullable(),
error: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type ReconcileRun = z.infer<typeof ReconcileRun>;
export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({
mode: true,
scope: true,
});
export type ReconcileRunCreate = z.infer<typeof ReconcileRunCreate>;
export const ReconcileRunUpdate = ReconcileRun.pick({
status: true,
resumeCursor: true,
stats: true,
taskId: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type ReconcileRunUpdate = z.infer<typeof ReconcileRunUpdate>;
export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]);
export type ServerItemType = z.infer<typeof ServerItemType>;
/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */
export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]);
export type ServerMatchStatus = z.infer<typeof ServerMatchStatus>;
/** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */
export const ServerMapping = EntityBase.extend({
mediaServerInstanceId: Id,
serverItemId: z.string().min(1),
itemType: ServerItemType,
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
serverPath: z.string().nullable(),
pathMappingId: Id.nullable(),
matchStatus: ServerMatchStatus,
versionInfo: JsonObject,
evidence: JsonObject,
observedAt: Timestamp,
});
export type ServerMapping = z.infer<typeof ServerMapping>;
export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
contentUnitId: true,
mediaAssetId: true,
serverPath: true,
pathMappingId: true,
matchStatus: true,
versionInfo: true,
evidence: true,
observedAt: true,
});
export type ServerMappingCreate = z.infer<typeof ServerMappingCreate>;
export const ServerMappingUpdate = ServerMappingCreate.omit({
mediaServerInstanceId: true,
serverItemId: true,
}).partial();
export type ServerMappingUpdate = z.infer<typeof ServerMappingUpdate>;
/** API Key(不含 key_hash) */
export const ApiKey = EntityBase.extend({
name: z.string().min(1),
scopes: z.array(ApiScope),
revokedAt: Timestamp.nullable(),
lastUsedAt: Timestamp.nullable(),
});
export type ApiKey = z.infer<typeof ApiKey>;
export const ApiKeyCreate = z.object({
name: z.string().min(1).max(80),
scopes: z.array(ApiScope).min(1),
});
export type ApiKeyCreate = z.infer<typeof ApiKeyCreate>;
/** 创建响应:明文 key 只在此返回一次 */
export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) });
export type ApiKeyCreated = z.infer<typeof ApiKeyCreated>;
export const ApiKeyUpdate = z.object({
name: z.string().min(1).max(80).optional(),
scopes: z.array(ApiScope).min(1).optional(),
revoked: z.literal(true).optional(),
});
export type ApiKeyUpdate = z.infer<typeof ApiKeyUpdate>;
/** 审计事件(只追加) */
export const AuditEvent = z.object({
id: Id,
at: Timestamp,
actorType: z.string(),
actorId: z.string().nullable(),
action: z.string().min(1),
targetType: z.string().nullable(),
targetId: z.string().nullable(),
payload: JsonObject,
traceId: z.string().nullable(),
});
export type AuditEvent = z.infer<typeof AuditEvent>;
export const AuditEventCreate = AuditEvent.omit({ id: true, at: true })
.partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true })
.extend({ actorType: ActorType });
export type AuditEventCreate = z.infer<typeof AuditEventCreate>;
export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]);
export type OutboxStatus = z.infer<typeof OutboxStatus>;
/** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */
export const OutboxEvent = z.object({
id: Id,
at: Timestamp,
type: z.string().min(1),
payload: JsonObject,
publishedAt: Timestamp.nullable(),
idempotencyKey: z.string().nullable(),
status: OutboxStatus,
attempts: NonNegInt,
nextAttemptAt: Timestamp,
lastError: JsonObject.nullable(),
});
export type OutboxEvent = z.infer<typeof OutboxEvent>;
export const OutboxEventCreate = OutboxEvent.pick({
type: true,
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
}).partial({
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
});
export type OutboxEventCreate = z.infer<typeof OutboxEventCreate>;
export const OutboxEventUpdate = OutboxEvent.pick({
status: true,
attempts: true,
nextAttemptAt: true,
lastError: true,
publishedAt: true,
}).partial();
export type OutboxEventUpdate = z.infer<typeof OutboxEventUpdate>;
export const SecretPurpose = z.enum([
"site_cookie",
"site_passkey",
"site_api_key",
"site_password",
"feed_url",
"downloader_password",
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"other",
]);
export type SecretPurpose = z.infer<typeof SecretPurpose>;
/** 加密凭证的元数据视图:密文/nonce 永不出 API */
export const Secret = EntityBase.extend({
name: z.string().min(1),
purpose: SecretPurpose,
algorithm: z.string(),
kdf: z.string(),
kdfParams: JsonObject,
keyVersion: z.number().int().min(1),
rotatedAt: Timestamp.nullable(),
});
export type Secret = z.infer<typeof Secret>;
/** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */
export const SecretCreate = z.object({
name: z.string().min(1).max(120),
purpose: SecretPurpose,
plaintext: z.string().min(1).max(16384),
});
export type SecretCreate = z.infer<typeof SecretCreate>;
export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) });
export type SecretUpdate = z.infer<typeof SecretUpdate>;
+288
View File
@@ -0,0 +1,288 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common";
import { MediaKind } from "./content";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
export const OrganizationRecord = EntityBase.extend({
taskId: Id.nullable(),
trigger: OrganizationTrigger,
actorType: ActorType,
actorId: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
downloadTaskId: Id.nullable(),
sourceDownloaderInstanceId: Id.nullable(),
sourcePathView: SourcePathView,
sourcePath: z.string().nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
reason: z.string().nullable(),
error: JsonObject.nullable(),
/** 冻结的整理计划 */
plan: JsonObject,
planFingerprint: z.string().nullable(),
fileTotal: NonNegInt,
fileSucceeded: NonNegInt,
fileFailed: NonNegInt,
fileSkipped: NonNegInt,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
taskId: true,
trigger: true,
actorType: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
action: true,
plan: true,
planFingerprint: true,
}).partial({
taskId: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
plan: true,
planFingerprint: true,
});
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
export const OrganizationRecordUpdate = OrganizationRecord.pick({
status: true,
reason: true,
error: true,
targetPath: true,
fileTotal: true,
fileSucceeded: true,
fileFailed: true,
fileSkipped: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
export const OrganizationFileResult = EntityBase.extend({
organizationRecordId: Id,
ordinal: z.number().int().nonnegative(),
assetOperationId: Id.nullable(),
mediaAssetId: Id.nullable(),
contentUnitId: Id.nullable(),
role: FileRole,
mode: OrganizationAction,
sourcePath: z.string().min(1),
sourceSizeBytes: NonNegInt.nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
status: FileResultStatus,
skipReason: z.string().nullable(),
error: JsonObject.nullable(),
checksum: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
organizationRecordId: true,
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
mode: true,
sourcePath: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
}).partial({
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
});
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
assetOperationId: true,
mediaAssetId: true,
targetPath: true,
status: true,
skipReason: true,
error: true,
checksum: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
export const PublicPathRef = z.object({
path: z.string().nullable(),
view: z.enum(["downloader", "local", "library"]),
downloaderInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
});
export type PublicPathRef = z.infer<typeof PublicPathRef>;
export const PublicOrganizationFile = z.object({
id: Id,
ordinal: z.number().int().nonnegative(),
role: FileRole,
mode: OrganizationAction,
status: FileResultStatus,
source: PublicPathRef,
target: PublicPathRef,
sizeBytes: NonNegInt.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
operationId: Id.nullable(),
skipReason: PublicReason.nullable(),
failure: PublicReason.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
taskId: Id.nullable(),
/** 相关资产操作账本 ID */
operationIds: z.array(Id),
media: z
.object({
entityId: Id,
kind: MediaKind,
title: z.string(),
year: z.number().int().nullable(),
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** 已确认的外部身份,如 tmdb:movie:693134 */
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
})
.nullable(),
trigger: OrganizationTrigger,
/** 手动 / 自动来源(agent 视为自动) */
origin: z.enum(["manual", "auto"]),
actor: z.object({ type: ActorType, id: z.string().nullable() }),
action: OrganizationAction,
status: OrganizationStatus,
source: PublicPathRef,
target: PublicPathRef,
reason: PublicReason.nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
/** includeFiles=false 时为 null */
files: z.array(PublicOrganizationFile).nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
export const OrganizationChangesQuery = z.object({
after: ChangeCursor.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
status: OrganizationStatus.optional(),
mediaEntityId: Id.optional(),
});
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
/**
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
*/
export const OrganizationChangesResponse = z.object({
items: z.array(PublicOrganizationRecord),
nextCursor: ChangeCursor,
hasMore: z.boolean(),
});
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
q: z.string().max(200).optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
export const OrganizeRequest = z
.object({
downloadTaskId: Id.optional(),
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
})
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
export const OrganizeAccepted = z.object({
recordId: Id.nullable(),
taskId: Id,
planId: Id.nullable(),
});
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
+247
View File
@@ -0,0 +1,247 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 规则域:订阅、策略版本、收录 Slot、偏好标签与抑制记录(REQ §6)
export const PolicyKind = z.enum(["acquisition", "preference", "retention", "cleanup", "instance", "automation"]);
export type PolicyKind = z.infer<typeof PolicyKind>;
export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]);
export type PolicyStatus = z.infer<typeof PolicyStatus>;
/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 policyKey 只有一个 published */
export const PolicyRevision = EntityBase.extend({
policyKey: z.string().min(1),
revision: z.number().int().min(1),
kind: PolicyKind,
status: PolicyStatus,
title: z.string().min(1),
summary: z.string().nullable(),
scope: JsonObject,
/** 结构化规则数据;不是可执行脚本或工作流 DSL */
rules: JsonObject,
preview: JsonObject.nullable(),
basedOnRevisionId: Id.nullable(),
createdBy: z.string().nullable(),
publishedAt: Timestamp.nullable(),
publishedBy: z.string().nullable(),
});
export type PolicyRevision = z.infer<typeof PolicyRevision>;
/** 新建总是草案;revision 由服务端分配 */
export const PolicyRevisionCreate = PolicyRevision.pick({
policyKey: true,
kind: true,
title: true,
summary: true,
scope: true,
rules: true,
basedOnRevisionId: true,
}).partial({ summary: true, scope: true, basedOnRevisionId: true });
export type PolicyRevisionCreate = z.infer<typeof PolicyRevisionCreate>;
/** 只有草案可改内容;发布/回滚走动作端点 */
export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial();
export type PolicyRevisionUpdate = z.infer<typeof PolicyRevisionUpdate>;
export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]);
export type SubscriptionMediaKind = z.infer<typeof SubscriptionMediaKind>;
export const SubscriptionMode = z.enum(["once", "continuous"]);
export type SubscriptionMode = z.infer<typeof SubscriptionMode>;
/** 仅本季 / 持续后续季(REQ §6.5) */
export const SeasonScope = z.enum(["current_season", "following_seasons"]);
export type SeasonScope = z.infer<typeof SeasonScope>;
export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]);
export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
export const Subscription = EntityBase.extend({
mediaEntityId: Id.nullable(),
seasonUnitId: Id.nullable(),
/** 作品尚未建档时的检索词 */
titleQuery: z.string().nullable(),
mediaKind: SubscriptionMediaKind,
mode: SubscriptionMode,
seasonScope: SeasonScope,
origin: SubscriptionOrigin,
priority: AcquisitionPriority,
status: SubscriptionStatus,
policyRevisionId: Id.nullable(),
params: JsonObject,
statusReason: z.string().nullable(),
lastCheckedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type Subscription = z.infer<typeof Subscription>;
export const SubscriptionCreate = Subscription.pick({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mediaKind: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.partial({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.refine((v) => !!v.mediaEntityId || !!v.titleQuery, { message: "mediaEntityId 与 titleQuery 至少给一个" });
export type SubscriptionCreate = z.infer<typeof SubscriptionCreate>;
export const SubscriptionUpdate = Subscription.pick({
mode: true,
seasonScope: true,
priority: true,
status: true,
policyRevisionId: true,
params: true,
statusReason: true,
}).partial();
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
/** Slot 状态(REQ §6.1) */
export const SlotStatus = z.enum([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
export type SlotStatus = z.infer<typeof SlotStatus>;
/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */
export const AcquisitionSlot = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
subscriptionId: Id.nullable(),
policyRevisionId: Id.nullable(),
/** 用户命名,如 archival / streaming */
name: z.string().min(1).max(64),
status: SlotStatus,
hardConditions: JsonObject,
softPreferences: JsonObject,
fallback: JsonObject,
upgradeThreshold: JsonObject,
completion: JsonObject,
/** 同组 Slot 在 requireDistinctAsset=true 时必须由不同物理资产满足(M2 校验) */
distinctGroup: z.string().nullable(),
requireDistinctAsset: z.boolean(),
currentAssetId: Id.nullable(),
reservedPlanId: Id.nullable(),
statusReason: z.string().nullable(),
lastEvaluatedAt: Timestamp.nullable(),
nextEvaluateAt: Timestamp.nullable(),
revision: z.number().int().min(1),
});
export type AcquisitionSlot = z.infer<typeof AcquisitionSlot>;
export const AcquisitionSlotCreate = AcquisitionSlot.pick({
mediaEntityId: true,
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
name: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
}).partial({
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
});
export type AcquisitionSlotCreate = z.infer<typeof AcquisitionSlotCreate>;
/** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */
export const AcquisitionSlotUpdate = AcquisitionSlotCreate.omit({
mediaEntityId: true,
contentUnitId: true,
name: true,
})
.partial()
.extend({ expectedRevision: z.number().int().min(1) });
export type AcquisitionSlotUpdate = z.infer<typeof AcquisitionSlotUpdate>;
/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */
export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]);
export type PreferenceKind = z.infer<typeof PreferenceKind>;
export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]);
export type PreferenceOrigin = z.infer<typeof PreferenceOrigin>;
export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]);
export type PreferenceStatus = z.infer<typeof PreferenceStatus>;
export const PreferenceTag = EntityBase.extend({
label: z.string().min(1).max(80),
kind: PreferenceKind,
scope: JsonObject,
rule: JsonObject,
/** 语义指纹(kind+scope+rule 规范化哈希),用于抑制匹配 */
fingerprint: z.string().min(1),
origin: PreferenceOrigin,
description: z.string().nullable(),
status: PreferenceStatus,
revision: z.number().int().min(1),
evidence: JsonObject,
policyRevisionId: Id.nullable(),
});
export type PreferenceTag = z.infer<typeof PreferenceTag>;
/** fingerprint 由服务端计算 */
export const PreferenceTagCreate = PreferenceTag.pick({
label: true,
kind: true,
scope: true,
rule: true,
origin: true,
description: true,
evidence: true,
}).partial({ scope: true, rule: true, origin: true, description: true, evidence: true });
export type PreferenceTagCreate = z.infer<typeof PreferenceTagCreate>;
export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true })
.partial()
.extend({ status: PreferenceStatus.optional() });
export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
/** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */
export const PreferenceSuppression = EntityBase.extend({
preferenceTagId: Id.nullable(),
fingerprint: z.string().min(1),
reason: z.string().nullable(),
evidence: JsonObject,
suppressedBy: z.string().min(1),
active: z.boolean(),
liftedAt: Timestamp.nullable(),
liftedBy: z.string().nullable(),
});
export type PreferenceSuppression = z.infer<typeof PreferenceSuppression>;
export const PreferenceSuppressionCreate = PreferenceSuppression.omit({
...SERVER_MANAGED,
active: true,
liftedAt: true,
liftedBy: true,
suppressedBy: true,
}).partial({ preferenceTagId: true, reason: true, evidence: true });
export type PreferenceSuppressionCreate = z.infer<typeof PreferenceSuppressionCreate>;
/** 唯一允许的更新是解除抑制 */
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
+157
View File
@@ -0,0 +1,157 @@
import { z } from "zod";
import { Id, Timestamp } from "./common";
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
// schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值);
// 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。
const Percent = z.number().min(1).max(100);
export const AutomationSettings = z.object({
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
enabled: z.boolean(),
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
readOnlyReconcile: z.boolean(),
});
export type AutomationSettings = z.infer<typeof AutomationSettings>;
const CacheShape = z.object({
/** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */
hardThresholdPercent: Percent,
/** 恢复水位:降到此值以下恢复因阈值暂停的任务 */
resumePercent: Percent,
checkIntervalSec: z.number().int().min(10).max(3600),
/** 缓存清理的目标使用率(低水位) */
cleanupTargetPercent: Percent,
});
/** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */
export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, {
message: "恢复水位必须低于硬阈值",
}).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" });
export type CacheSettings = z.infer<typeof CacheSettings>;
export const OrganizeMode = z.enum(["copy", "move"]);
export type OrganizeMode = z.infer<typeof OrganizeMode>;
export const OrganizeSettings = z.object({
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
});
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
export const RetentionSettings = z.object({
/** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */
seedRetentionDays: z.number().int().min(0).max(365),
/** 观察/日志类数据保留天数 */
logRetentionDays: z.number().int().min(1).max(3650),
});
export type RetentionSettings = z.infer<typeof RetentionSettings>;
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
/** Pi Agent 设置(REQ §10.5):上游/模型/思考档位/上下文预算/压缩/子代理;密钥只存 secret 引用 */
export const PiSettings = z.object({
provider: z.string().min(1),
baseUrl: z.url().nullable(),
model: z.string().min(1).nullable(),
thinkingLevel: ThinkingLevel,
/** 上下文预算(token) */
contextBudget: z.number().int().min(4096).max(2_000_000),
compaction: z.object({
enabled: z.boolean(),
/** 达到上下文预算的该比例时触发压缩 */
thresholdRatio: z.number().min(0.3).max(0.95),
}),
subagentsEnabled: z.boolean(),
apiKeySecretRef: Id.nullable(),
});
export type PiSettings = z.infer<typeof PiSettings>;
/** 每日预算与并发(REQ §6.4、§10.5) */
export const BudgetSettings = z.object({
maxNewTitlesPerDay: z.number().int().min(0),
maxDownloadBytesPerDay: z.number().int().min(0),
maxConcurrentDownloads: z.number().int().min(1).max(100),
agentMaxRunsPerDay: z.number().int().min(0),
agentMaxCostUsdPerDay: z.number().min(0),
});
export type BudgetSettings = z.infer<typeof BudgetSettings>;
export const GlobalSettings = z.object({
automation: AutomationSettings,
cache: CacheSettings,
organize: OrganizeSettings,
retention: RetentionSettings,
pi: PiSettings,
budget: BudgetSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
/** settings 表的合法 key */
export const SettingsKey = GlobalSettings.keyof();
export type SettingsKey = z.infer<typeof SettingsKey>;
/** 缺省设置(settings 表缺 key 或缺字段时使用) */
export const DEFAULT_SETTINGS: GlobalSettings = {
automation: { enabled: true, readOnlyReconcile: false },
cache: { hardThresholdPercent: 90, resumePercent: 85, checkIntervalSec: 60, cleanupTargetPercent: 80 },
organize: { defaultMode: "copy", allowCrossVolumeMove: true },
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
provider: "anthropic",
baseUrl: null,
model: null,
thinkingLevel: "medium",
contextBudget: 128_000,
compaction: { enabled: true, thresholdRatio: 0.8 },
subagentsEnabled: false,
apiKeySecretRef: null,
},
budget: {
maxNewTitlesPerDay: 20,
maxDownloadBytesPerDay: 500 * 1024 ** 3,
maxConcurrentDownloads: 5,
agentMaxRunsPerDay: 50,
agentMaxCostUsdPerDay: 5,
},
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
export const GlobalSettingsUpdate = z.object({
automation: AutomationSettings.partial().optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
retention: RetentionSettings.partial().optional(),
pi: PiSettings.omit({ apiKeySecretRef: true })
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
apiKey: z.string().min(1).max(4096).nullable().optional(),
})
.optional(),
budget: BudgetSettings.partial().optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
return {
automation: { ...current.automation, ...patch.automation },
cache: { ...current.cache, ...patch.cache },
organize: { ...current.organize, ...patch.organize },
retention: { ...current.retention, ...patch.retention },
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
budget: { ...current.budget, ...patch.budget },
};
}
/** settings 表行 */
export const SettingEntry = z.object({
key: SettingsKey,
value: z.unknown(),
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
+261
View File
@@ -0,0 +1,261 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4)
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "generic_rss"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
export type SiteHealth = z.infer<typeof SiteHealth>;
export const CircuitState = z.enum(["closed", "open", "half_open"]);
export type CircuitState = z.infer<typeof CircuitState>;
/** 站点级限流(快照、实时搜索、浏览器共享) */
export const SiteRateLimit = z.object({
requestsPerMinute: z.number().int().positive().optional(),
minIntervalMs: z.number().int().nonnegative().optional(),
concurrency: z.number().int().positive().optional(),
});
export type SiteRateLimit = z.infer<typeof SiteRateLimit>;
export const Site = EntityBase.extend({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().min(1),
adapter: SiteAdapter,
baseUrl: z.url(),
enabled: z.boolean(),
rateLimit: SiteRateLimit,
config: JsonObject,
healthStatus: SiteHealth,
circuitState: CircuitState,
circuitOpenUntil: Timestamp.nullable(),
snapshotEnabled: z.boolean(),
snapshotIntervalSec: z.number().int().positive().nullable(),
lastSnapshotAt: Timestamp.nullable(),
snapshotCursor: JsonObject,
});
export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
key: true,
name: true,
adapter: true,
baseUrl: true,
enabled: true,
rateLimit: true,
config: true,
snapshotEnabled: true,
snapshotIntervalSec: true,
}).partial({ enabled: true, rateLimit: true, config: true, snapshotEnabled: true, snapshotIntervalSec: true });
export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
export type SiteUpdate = z.infer<typeof SiteUpdate>;
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
export type SiteAuthKind = z.infer<typeof SiteAuthKind>;
export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]);
export type SiteAccountStatus = z.infer<typeof SiteAccountStatus>;
export const SiteAccount = EntityBase.extend({
siteId: Id,
label: z.string().min(1),
authKind: SiteAuthKind,
secretRef: Id.nullable(),
enabled: z.boolean(),
status: SiteAccountStatus,
lastVerifiedAt: Timestamp.nullable(),
userInfo: JsonObject,
});
export type SiteAccount = z.infer<typeof SiteAccount>;
export const SiteAccountCreate = SiteAccount.pick({ siteId: true, label: true, authKind: true, enabled: true })
.partial({ enabled: true })
.extend({ credential: z.string().min(1).max(16384) });
export type SiteAccountCreate = z.infer<typeof SiteAccountCreate>;
export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial();
export type SiteAccountUpdate = z.infer<typeof SiteAccountUpdate>;
export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]);
export type FeedPurpose = z.infer<typeof FeedPurpose>;
export const FeedKind = z.enum(["rss", "atom", "list", "query"]);
export type FeedKind = z.infer<typeof FeedKind>;
export const FeedHealth = z.enum(["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"]);
export type FeedHealth = z.infer<typeof FeedHealth>;
export const FeedSource = EntityBase.extend({
siteId: Id.nullable(),
siteAccountId: Id.nullable(),
name: z.string().min(1),
purpose: FeedPurpose,
kind: FeedKind,
urlSecretRef: Id.nullable(),
/** 脱敏 URL(passkey 等参数替换为 [REDACTED]) */
urlDisplay: z.string(),
parserVersion: z.string(),
enabled: z.boolean(),
pollIntervalSec: z.number().int().min(60),
filter: JsonObject,
cursor: JsonObject,
etag: z.string().nullable(),
lastModified: z.string().nullable(),
lastFetchAt: Timestamp.nullable(),
lastSuccessAt: Timestamp.nullable(),
nextFetchAt: Timestamp.nullable(),
consecutiveFailures: NonNegInt,
backoffUntil: Timestamp.nullable(),
healthStatus: FeedHealth,
lastError: JsonObject.nullable(),
});
export type FeedSource = z.infer<typeof FeedSource>;
/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */
export const FeedSourceCreate = FeedSource.pick({
siteId: true,
siteAccountId: true,
name: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.partial({
siteId: true,
siteAccountId: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.extend({ url: z.url() });
export type FeedSourceCreate = z.infer<typeof FeedSourceCreate>;
export const FeedSourceUpdate = FeedSourceCreate.partial();
export type FeedSourceUpdate = z.infer<typeof FeedSourceUpdate>;
export const ResolutionStatus = z.enum(["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"]);
export type ResolutionStatus = z.infer<typeof ResolutionStatus>;
export const ReleaseAvailability = z.enum(["active", "site_disabled", "expired", "unknown"]);
export type ReleaseAvailability = z.infer<typeof ReleaseAvailability>;
/** 促销状态必须带观察时间与过期时间,下单前按策略复核(REQ §4.3) */
export const Promotion = z.object({
kind: z.string().optional(),
observedAt: Timestamp.optional(),
expiresAt: Timestamp.nullable().optional(),
raw: z.unknown().optional(),
});
export type Promotion = z.infer<typeof Promotion>;
export const TorrentFileEntry = z.object({
path: z.string(),
size: NonNegInt,
});
export type TorrentFileEntry = z.infer<typeof TorrentFileEntry>;
export const SourceRelease = EntityBase.extend({
siteId: Id,
siteReleaseId: z.string().min(1),
title: z.string(),
subtitle: z.string().nullable(),
categoryRaw: z.string().nullable(),
tagsRaw: z.array(z.string()),
sizeBytes: NonNegInt.nullable(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
fileList: z.array(TorrentFileEntry).nullable(),
promotion: Promotion,
promotionExpiresAt: Timestamp.nullable(),
seeders: z.number().int().nullable(),
leechers: z.number().int().nullable(),
/** 站点发布时间;区别于 firstSeenAt(本系统首次发现) */
sourcePublishedAt: Timestamp.nullable(),
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
detailFetchedAt: Timestamp.nullable(),
/** 标题解析结果(标题/年份/季集/分辨率/来源/编码/发布组…) */
parsed: JsonObject,
parserVersion: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
resolutionStatus: ResolutionStatus,
resolutionEvidence: JsonObject,
availability: ReleaseAvailability,
raw: JsonObject,
});
export type SourceRelease = z.infer<typeof SourceRelease>;
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
export const SourceReleaseCreate = SourceRelease.omit({
...SERVER_MANAGED,
firstSeenAt: true,
lastSeenAt: true,
}).partial({
subtitle: true,
categoryRaw: true,
tagsRaw: true,
sizeBytes: true,
infohashV1: true,
infohashV2: true,
fileList: true,
promotion: true,
promotionExpiresAt: true,
seeders: true,
leechers: true,
sourcePublishedAt: true,
detailFetchedAt: true,
parsed: true,
parserVersion: true,
mediaEntityId: true,
contentUnitId: true,
resolutionStatus: true,
resolutionEvidence: true,
availability: true,
raw: true,
});
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
export const SourceReleaseUpdate = SourceReleaseCreate.omit({ siteId: true, siteReleaseId: true }).partial();
export type SourceReleaseUpdate = z.infer<typeof SourceReleaseUpdate>;
export const ObservationStatus = z.enum(["new", "linked", "ignored", "error"]);
export type ObservationStatus = z.infer<typeof ObservationStatus>;
export const Observation = EntityBase.extend({
feedId: Id,
guid: z.string().min(1),
contentDigest: z.string().min(1),
sourceReleaseId: Id.nullable(),
title: z.string(),
/** 已脱敏的链接;原始下载链接不对外 */
linkRedacted: z.string().nullable(),
raw: JsonObject,
sourcePublishedAt: Timestamp.nullable(),
fetchedAt: Timestamp,
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
status: ObservationStatus,
resolveAttempts: NonNegInt,
nextResolveAt: Timestamp.nullable(),
lastError: JsonObject.nullable(),
});
export type Observation = z.infer<typeof Observation>;
export const ObservationCreate = Observation.pick({
feedId: true,
guid: true,
contentDigest: true,
sourceReleaseId: true,
title: true,
linkRedacted: true,
raw: true,
sourcePublishedAt: true,
fetchedAt: true,
}).partial({ sourceReleaseId: true, linkRedacted: true, raw: true, sourcePublishedAt: true, fetchedAt: true });
export type ObservationCreate = z.infer<typeof ObservationCreate>;
export const ObservationUpdate = Observation.pick({
contentDigest: true,
sourceReleaseId: true,
title: true,
raw: true,
status: true,
resolveAttempts: true,
nextResolveAt: true,
lastError: true,
}).partial();
export type ObservationUpdate = z.infer<typeof ObservationUpdate>;
+198
View File
@@ -0,0 +1,198 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import * as C from "../src";
// 与 packages/db/src/types.ts(从迁移反射生成)做文本级对照:
// 1) 每张表的列 ⇔ 对应 Row schema 的字段(camelCase),只允许显式列出的差异;
// 2) 每个 CHECK 枚举 ⇔ contracts 中对应 z.enum 的取值。
// 只读文件,不引入 @umt/db 依赖。
const typesSrc = readFileSync(new URL("../../db/src/types.ts", import.meta.url), "utf8");
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
function tableColumns(): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const m of typesSrc.matchAll(/export interface (\w+)Table \{\n([\s\S]*?)\n\}/g)) {
const cols = [...m[2]!.matchAll(/^ {2}(\w+):/gm)].map((x) => x[1]!);
out.set(m[1]!, cols);
}
return out;
}
function enumAliases(): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const m of typesSrc.matchAll(/export type (\w+) =([^;]+);/g)) {
if (m[1] === "Timestamptz" || m[1] === "Json" || m[2]!.includes("<")) continue;
out.set(
m[1]!,
[...m[2]!.matchAll(/"([^"]*)"/g)].map((x) => x[1]!),
);
}
return out;
}
type Obj = z.ZodObject;
/** 表(PascalCase)→ Row schema 与允许的差异 */
const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }> = {
AcquisitionPlan: { row: C.AcquisitionPlan },
AcquisitionSlot: { row: C.AcquisitionSlot },
AgentRun: { row: C.AgentRun },
ApiKey: { row: C.ApiKey, dbOnly: ["keyHash"] },
Approval: { row: C.Approval },
Artifact: { row: C.Artifact },
AssetLocation: { row: C.AssetLocation },
AssetOperation: { row: C.AssetOperation },
AssetUnitCoverage: { row: C.AssetUnitCoverage },
AuditEvent: { row: C.AuditEvent },
AutomationGrant: { row: C.AutomationGrant },
ContentUnit: { row: C.ContentUnit },
Decision: { row: C.Decision },
DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance },
ExternalIdentity: { row: C.ExternalIdentity },
FeedSource: { row: C.FeedSource },
IdentityRevision: { row: C.IdentityRevision },
LibraryRoot: { row: C.LibraryRoot },
MediaAsset: { row: C.MediaAsset },
MediaEntity: { row: C.MediaEntity },
MediaServerInstance: { row: C.MediaServerInstance },
Observation: { row: C.Observation },
OrganizationFileResult: { row: C.OrganizationFileResult },
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
OutboxEvent: { row: C.OutboxEvent },
PathBinding: { row: C.PathBinding },
PathMapping: { row: C.PathMapping },
PolicyRevision: { row: C.PolicyRevision },
PreferenceSuppression: { row: C.PreferenceSuppression },
PreferenceTag: { row: C.PreferenceTag },
ReconcileRun: { row: C.ReconcileRun },
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
ServerMapping: { row: C.ServerMapping },
Settings: { row: C.SettingEntry },
Site: { row: C.Site },
SiteAccount: { row: C.SiteAccount },
SourceRelease: { row: C.SourceRelease },
Subscription: { row: C.Subscription },
Task: { row: C.Task },
TitleAlias: { row: C.TitleAlias },
};
/** DB 枚举类型名 → contracts 枚举 */
const ENUMS: Record<string, z.ZodEnum> = {
AcquisitionPlanPurpose: C.PlanPurpose,
AcquisitionPlanStatus: C.PlanStatus,
AcquisitionPlanPriority: C.AcquisitionPriority,
AcquisitionSlotStatus: C.SlotStatus,
AgentRunTrigger: C.AgentTrigger,
AgentRunStatus: C.AgentRunStatus,
ApprovalStatus: C.ApprovalStatus,
ArtifactKind: C.ArtifactKind,
AssetLocationLocationKind: C.LocationKind,
AssetLocationStatus: C.LocationStatus,
AssetOperationPhase: C.OperationPhase,
AssetOperationKind: C.OperationKind,
AssetOperationStatus: C.OperationStatus,
AssetUnitCoverageCoverage: C.CoverageKind,
AssetUnitCoverageSegment: C.Segment,
AutomationGrantStatus: C.GrantStatus,
ContentUnitKind: C.ContentUnitKind,
ContentUnitContentType: C.ContentType,
ContentUnitSegment: C.Segment,
ContentUnitStatus: C.ContentUnitStatus,
DecisionKind: C.DecisionKind,
DecisionStatus: C.DecisionStatus,
DecisionSeverity: C.Severity,
DownloadTaskOrigin: C.DownloadOrigin,
DownloadTaskStatus: C.DownloadStatus,
DownloadTaskPauseReason: C.PauseReason,
DownloaderInstanceKind: C.DownloaderKind,
DownloaderInstanceHealthStatus: C.InstanceHealth,
ExternalIdentityEntityType: C.ExternalEntityType,
ExternalIdentityStatus: C.IdentityStatus,
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
ExternalIdentityVerifyStatus: C.IdentityVerifyStatus,
FeedSourcePurpose: C.FeedPurpose,
FeedSourceKind: C.FeedKind,
FeedSourceHealthStatus: C.FeedHealth,
IdentityRevisionAction: C.IdentityRevisionAction,
IdentityRevisionFromStatus: C.IdentityStatus,
IdentityRevisionToStatus: C.IdentityStatus,
IdentityRevisionActorType: C.ActorType,
LibraryRootMediaKind: C.LibraryMediaKind,
LibraryRootManagementMode: C.ManagementMode,
LibraryRootHealthStatus: C.InstanceHealth,
MediaAssetKind: C.AssetKind,
MediaAssetStatus: C.AssetStatus,
MediaEntityKind: C.MediaKind,
MediaEntityStatus: C.MediaEntityStatus,
MediaServerInstanceKind: C.MediaServerKind,
MediaServerInstanceHealthStatus: C.InstanceHealth,
ObservationStatus: C.ObservationStatus,
OrganizationFileResultRole: C.FileRole,
OrganizationFileResultMode: C.OrganizationAction,
OrganizationFileResultStatus: C.FileResultStatus,
OrganizationRecordTrigger: C.OrganizationTrigger,
OrganizationRecordActorType: C.ActorType,
OrganizationRecordSourcePathView: C.SourcePathView,
OrganizationRecordAction: C.OrganizationAction,
OrganizationRecordStatus: C.OrganizationStatus,
OutboxEventStatus: C.OutboxStatus,
PathBindingBindingKind: C.BindingKind,
PathBindingStatus: C.BindingStatus,
PathMappingViewKind: C.PathViewKind,
PathMappingVerifyStatus: C.PathMappingVerifyStatus,
PolicyRevisionKind: C.PolicyKind,
PolicyRevisionStatus: C.PolicyStatus,
PreferenceTagKind: C.PreferenceKind,
PreferenceTagOrigin: C.PreferenceOrigin,
PreferenceTagStatus: C.PreferenceStatus,
ReconcileRunKind: C.ReconcileKind,
ReconcileRunMode: C.ReconcileMode,
ReconcileRunStatus: C.ReconcileStatus,
SecretPurpose: C.SecretPurpose,
ServerMappingItemType: C.ServerItemType,
ServerMappingMatchStatus: C.ServerMatchStatus,
SiteAdapter: C.SiteAdapter,
SiteHealthStatus: C.SiteHealth,
SiteCircuitState: C.CircuitState,
SiteAccountAuthKind: C.SiteAuthKind,
SiteAccountStatus: C.SiteAccountStatus,
SourceReleaseResolutionStatus: C.ResolutionStatus,
SourceReleaseAvailability: C.ReleaseAvailability,
SubscriptionMediaKind: C.SubscriptionMediaKind,
SubscriptionMode: C.SubscriptionMode,
SubscriptionSeasonScope: C.SeasonScope,
SubscriptionOrigin: C.SubscriptionOrigin,
SubscriptionPriority: C.AcquisitionPriority,
SubscriptionStatus: C.SubscriptionStatus,
TaskStatus: C.TaskStatus,
TitleAliasKind: C.AliasKind,
TitleAliasSource: C.AliasSource,
};
describe("contracts ⇔ db 表结构同步", () => {
const tables = tableColumns();
it("每张表都有 Row schema", () => {
expect([...tables.keys()].sort()).toEqual(Object.keys(ROWS).sort());
});
for (const [table, spec] of Object.entries(ROWS)) {
it(`${table} 列与 Row 字段一致`, () => {
const dbCols = (tables.get(table) ?? []).map(camel).filter((c) => !spec.dbOnly?.includes(c));
const apiCols = Object.keys(spec.row.shape).filter((c) => !spec.apiOnly?.includes(c));
expect(apiCols.sort()).toEqual(dbCols.sort());
});
}
it("每个 DB 枚举都有对应 contracts 枚举且取值一致", () => {
const aliases = enumAliases();
expect([...aliases.keys()].sort()).toEqual(Object.keys(ENUMS).sort());
for (const [name, values] of aliases) {
expect(ENUMS[name]!.options, name).toEqual(values);
}
});
});
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import * as C from "../src";
// 对 contracts 导出的每个 zod schema 做正反例 parse:
// 正例由 sample() 按 schema 结构生成(字符串/数字按候选值逐个试到通过),个别 refine 由 OVERRIDES 修正;
// 反例把一个必填字段替换成非法值(对象)或直接传 Symbol(非对象)。
interface Def {
type: string;
innerType?: z.ZodType;
shape?: Record<string, z.ZodType>;
options?: z.ZodType[];
entries?: Record<string, string>;
values?: unknown[];
element?: z.ZodType;
in?: z.ZodType;
defaultValue?: unknown;
}
const def = (s: z.ZodType) => (s as unknown as { _zod: { def: Def } })._zod.def;
const UUID = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
const STRING_CANDIDATES = [
UUID,
"2026-09-27T08:00:00.000Z",
"2026-09-27",
"https://example.test/x",
"a".repeat(40),
"/media/movies",
"tmdb",
"123",
"CatalogLookupInput",
"catalog.lookup",
"测试 Test",
];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
function pick<T>(schema: z.ZodType, candidates: readonly T[]): T {
const hit = candidates.find((c) => schema.safeParse(c).success);
if (hit === undefined) throw new Error(`没有候选值能通过 ${def(schema).type}`);
return hit;
}
function sample(schema: z.ZodType): unknown {
const d = def(schema);
switch (d.type) {
case "string":
return pick(schema, STRING_CANDIDATES);
case "number":
return pick(schema, NUMBER_CANDIDATES);
case "boolean":
return true;
case "enum":
return Object.values(d.entries!)[0];
case "literal":
return d.values![0];
case "nullable":
case "optional":
case "default":
case "prefault":
case "readonly":
return sample(d.innerType!);
case "array":
return [sample(d.element!)];
case "record":
return { k: "v" };
case "unknown":
return { any: 1 };
case "union":
return sample(d.options![0]!);
case "pipe":
return sample(d.in!);
case "object": {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(d.shape!)) out[k] = sample(v);
return out;
}
default:
throw new Error(`sample 未支持 ${d.type}`);
}
}
/** 需要满足跨字段 refine 的正例修正 */
const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
PathMappingCreate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
PathMappingUpdate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
CatalogLookupInput: (v) => v,
IdentityResolveInput: (v) => v,
AssetLocationCreate: (v) => v,
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
GlobalSettings: () => C.DEFAULT_SETTINGS,
TypedEventEnvelope: () => ({
seq: 1,
at: "2026-09-27T08:00:00Z",
type: "system.heartbeat",
payload: { role: "all" },
}),
};
const isSchema = (v: unknown): v is z.ZodType => v instanceof z.ZodType;
const schemas: [string, z.ZodType][] = [];
for (const [k, v] of Object.entries(C) as [string, unknown][]) if (isSchema(v)) schemas.push([k, v]);
describe("所有导出 schema 的正反例", () => {
it("导出了足够多的 schema", () => {
expect(schemas.length).toBeGreaterThan(150);
});
for (const [name, schema] of schemas) {
it(name, () => {
let good = sample(schema);
const fix = OVERRIDES[name];
if (fix && good && typeof good === "object") good = fix(good as Record<string, unknown>);
const ok = schema.safeParse(good);
if (!ok.success) throw new Error(`${name} 正例失败: ${ok.error.message}\n${JSON.stringify(good)}`);
// 反例
const d = def(schema);
if (d.type === "object") {
const shape = d.shape!;
const key = Object.keys(shape).find(
(k) => !shape[k]!.safeParse(undefined).success && def(shape[k]!).type !== "unknown",
);
const bad = key ? { ...(good as object), [key]: Symbol("bad") } : Symbol("bad");
expect(schema.safeParse(bad).success, `${name} 反例应失败(${key ?? "整体"})`).toBe(false);
} else {
expect(schema.safeParse(Symbol("bad")).success, `${name} 反例应失败`).toBe(false);
}
});
}
});
describe("关键合同行为", () => {
it("Slot 状态集合与 REQ §6.1 一致", () => {
expect(C.SlotStatus.options).toEqual([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
});
it("CacheSettings 拒绝恢复水位 ≥ 硬阈值", () => {
expect(C.CacheSettings.safeParse({ ...C.DEFAULT_SETTINGS.cache, resumePercent: 95 }).success).toBe(false);
});
it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => {
const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
expect(patch).toEqual({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false });
expect(merged.pi).toEqual(C.DEFAULT_SETTINGS.pi);
expect(C.GlobalSettings.safeParse(merged).success).toBe(true);
});
it("OrganizationChangesQuery 接受查询串并给默认值", () => {
expect(C.OrganizationChangesQuery.parse({ after: "42", limit: "10", includeFiles: "false" })).toEqual({
after: "42",
limit: 10,
includeFiles: false,
});
expect(C.OrganizationChangesQuery.safeParse({ after: "-1" }).success).toBe(false);
});
it("ExternalIdentityCreate 要求且只能有一个本地目标", () => {
const base = { provider: "tmdb", entityType: "movie", externalId: "1" };
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID }).success).toBe(true);
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID, contentUnitId: UUID }).success).toBe(
false,
);
});
it("能力目录覆盖首批 14 个能力且 schema 引用都存在", () => {
expect(Object.keys(C.CAPABILITY_CATALOG).sort()).toEqual([...C.CAPABILITY_IDS].sort());
const exported = C as Record<string, unknown>;
for (const cap of Object.values(C.CAPABILITY_CATALOG)) {
expect(C.CapabilityDescriptor.safeParse(cap).success).toBe(true);
expect(isSchema(exported[cap.inputSchema]), cap.inputSchema).toBe(true);
expect(isSchema(exported[cap.outputSchema]), cap.outputSchema).toBe(true);
expect(exported[cap.inputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].input);
}
});
it("事件 payload 表覆盖全部事件类型", () => {
expect(Object.keys(C.EVENT_PAYLOADS).sort()).toEqual(Object.values(C.EventType).sort());
expect(
C.parseEventPayload("task.status_changed", {
taskId: UUID,
kind: "ingestFeed",
from: null,
to: "running",
error: null,
}),
).not.toBeNull();
expect(C.parseEventPayload("task.status_changed", { taskId: "x" })).toBeNull();
expect(C.isEventType("nope.x")).toBe(false);
});
it("API 端点路径唯一且 buildPath 填参", () => {
const eps = C.listEndpoints();
const keys = eps.map((e) => `${e.method} ${e.path}`);
expect(new Set(keys).size).toBe(keys.length);
expect(C.buildPath(C.Api.tasks.cancel.path, { id: UUID })).toBe(`/api/v1/tasks/${UUID}/cancel`);
expect(() => C.buildPath(C.Api.tasks.get.path)).toThrow();
// 公开整理记录读写分权
expect(C.Api.organization.changes.scope).toBe("organize.read");
expect(C.Api.organization.create.scope).toBe("organize.execute");
});
});
+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"]
}