diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 04df88f0..7f52e0ae 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -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 里仍会填默认值);缺省值放常量。 diff --git a/packages/contracts/src/agent.ts b/packages/contracts/src/agent.ts new file mode 100644 index 00000000..1f4f6a53 --- /dev/null +++ b/packages/contracts/src/agent.ts @@ -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; +export const AgentRunStatus = z.enum([ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "cancelled", + "budget_exceeded", +]); +export type AgentRunStatus = z.infer; + +/** 单次运行预算(调用次数/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; + +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; + +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; +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; +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; + +export const ArtifactKind = z.enum(["evidence", "plan", "proposal", "report", "snapshot", "log"]); +export type ArtifactKind = z.infer; + +/** 证据/计划/提案产物:提案不直接晋升为事实 */ +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; +/** 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; +/** 产物内容不可变;只能调整过期时间 */ +export const ArtifactUpdate = z.object({ expiresAt: Timestamp.nullable() }); +export type ArtifactUpdate = z.infer; + +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; +export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]); +export type DecisionStatus = z.infer; +export const Severity = z.enum(["info", "warning", "critical"]); +export type Severity = z.infer; + +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; + +/** 待处理中心条目;同一 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; +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; +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; +/** 用户处理待处理事项:选一个选项或关闭 */ +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; + +export const ApprovalStatus = z.enum(["pending", "approved", "rejected", "revoked", "expired", "consumed"]); +export type ApprovalStatus = z.infer; + +/** 单次审批:绑定 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; +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; +/** 批准/拒绝时回传 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; + +export const GrantStatus = z.enum(["active", "revoked", "expired"]); +export type GrantStatus = z.infer; + +/** 持续授权:绑定策略版本、动作、对象范围与预算 */ +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; +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; +/** 只能撤销或缩短有效期,不能就地扩大范围(扩大 = 新授权) */ +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; diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts new file mode 100644 index 00000000..fdb52963 --- /dev/null +++ b/packages/contracts/src/api.ts @@ -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: E): E => e; + +/** 列表查询:游标分页 + 资源自有过滤 */ +const listQuery = (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( + 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 { + 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["body"] extends z.ZodType ? z.input : never; +export type EndpointResponse = z.infer; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts new file mode 100644 index 00000000..4e744fd5 --- /dev/null +++ b/packages/contracts/src/assets.ts @@ -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; +export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]); +export type AssetStatus = z.infer; + +/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 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; + +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; +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; +export const MediaAssetUpdate = MediaAssetCreate.partial(); +export type MediaAssetUpdate = z.infer; + +export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]); +export type CoverageKind = z.infer; + +/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */ +export const AssetUnitCoverage = EntityBase.extend({ + mediaAssetId: Id, + contentUnitId: Id, + coverage: CoverageKind, + segment: Segment.nullable(), + evidence: JsonObject, +}); +export type AssetUnitCoverage = z.infer; +export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({ + coverage: true, + segment: true, + evidence: true, +}); +export type AssetUnitCoverageCreate = z.infer; +export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({ + mediaAssetId: true, + contentUnitId: true, +}).partial(); +export type AssetUnitCoverageUpdate = z.infer; + +export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]); +export type LocationKind = z.infer; +/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */ +export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]); +export type LocationStatus = z.infer; + +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; +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; +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; + +export const BindingKind = z.enum(["entity", "season"]); +export type BindingKind = z.infer; +export const BindingStatus = z.enum(["active", "conflict", "superseded"]); +export type BindingStatus = z.infer; + +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; +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; +/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */ +export const PathBindingUpdate = z.object({ + status: BindingStatus.optional(), + supersededById: Id.nullable().optional(), + note: z.string().nullable().optional(), +}); +export type PathBindingUpdate = z.infer; diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts new file mode 100644 index 00000000..4cafc07e --- /dev/null +++ b/packages/contracts/src/capabilities-io.ts @@ -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; + +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; + +export type CatalogLookupInput = z.infer; +export type CatalogLookupOutput = z.infer; +export type ReleaseSearchInput = z.infer; +export type ReleaseSearchOutput = z.infer; +export type ReleaseInspectInput = z.infer; +export type ReleaseInspectOutput = z.infer; +export type IdentityResolveInput = z.infer; +export type IdentityResolveOutput = z.infer; +export type DownloadPlanInput = z.infer; +export type DownloadPlanOutput = z.infer; +export type DownloadSubmitInput = z.infer; +export type DownloadSubmitOutput = z.infer; +export type OrganizePlanInput = z.infer; +export type OrganizePlanOutput = z.infer; +export type OrganizeExecuteInput = z.infer; +export type OrganizeExecuteOutput = z.infer; +export type TaskInspectInput = z.infer; +export type TaskInspectOutput = z.infer; +export type TaskRetryInput = z.infer; +export type TaskRetryOutput = z.infer; +export type TaskCancelInput = z.infer; +export type TaskCancelOutput = z.infer; +export type PolicyPreviewInput = z.infer; +export type PolicyPreviewOutput = z.infer; +export type PolicyApplyInput = z.infer; +export type PolicyApplyOutput = z.infer; +export type InventoryQueryInput = z.infer; +export type InventoryQueryOutput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index f873e257..2aff4c78 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -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; + +/** 接口类别(REQ §12.3):read / preview / propose / approve / execute */ +export const CapabilityKind = z.enum(["read", "preview", "propose", "approve", "execute"]); +export type CapabilityKind = z.infer; + +/** 首批能力 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; + +/** 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; + +/** 首批能力的描述(inputSchema/outputSchema 名对应 capabilities-io.ts 中的导出) */ +export const CAPABILITY_CATALOG: Record = { + "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, + }; +} diff --git a/packages/contracts/src/common.ts b/packages/contracts/src/common.ts index 93aea7ce..351b482d 100644 --- a/packages/contracts/src/common.ts +++ b/packages/contracts/src/common.ts @@ -91,3 +91,32 @@ export const HealthStatus = z.object({ time: Timestamp, }); export type HealthStatus = z.infer; + +/** 日期(无时区),如 air_date:YYYY-MM-DD */ +export const DateOnly = z.iso.date(); +export type DateOnly = z.infer; + +/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */ +export const JsonObject = z.record(z.string(), z.unknown()); +export type JsonObject = z.infer; + +/** 字节数、计数等非负整数(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; + +/** 实体行的公共字段 */ +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; diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts new file mode 100644 index 00000000..22a6b46f --- /dev/null +++ b/packages/contracts/src/content.ts @@ -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; +export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]); +export type MediaEntityStatus = z.infer; + +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; +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; +/** 合并通过专门动作完成,Update 不接受 status=merged */ +export const MediaEntityUpdate = MediaEntityCreate.partial().extend({ + status: MediaEntityStatus.exclude(["merged"]).optional(), +}); +export type MediaEntityUpdate = z.infer; + +export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]); +export type ContentUnitKind = z.infer; +/** 综艺内容类型(REQ §5.1) */ +export const ContentType = z.enum([ + "main", + "extra", + "behind_the_scenes", + "performance_only", + "preview", + "reaction", + "unknown", +]); +export type ContentType = z.infer; +export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]); +export type Segment = z.infer; +export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]); +export type ContentUnitStatus = z.infer; + +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; +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; +export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial(); +export type ContentUnitUpdate = z.infer; + +export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]); +export type AliasKind = z.infer; +export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]); +export type AliasSource = z.infer; + +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; +/** 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; +export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial(); +export type TitleAliasUpdate = z.infer; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 960431a9..cb3682a2 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -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= 补读 */ export const EventEnvelope = z.object({ @@ -15,10 +19,30 @@ export type EventEnvelope

= { 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; + +export type EventPayload = z.infer<(typeof EVENT_PAYLOADS)[T]>; + +/** 已知事件的判别联合:`{ type, payload }` */ +export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload } }[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(type: T, payload: unknown): EventPayload | null { + const schema: z.ZodType = EVENT_PAYLOADS[type]; + const r = schema.safeParse(payload); + return r.success ? (r.data as EventPayload) : null; +} diff --git a/packages/contracts/src/execution.ts b/packages/contracts/src/execution.ts new file mode 100644 index 00000000..14cd2052 --- /dev/null +++ b/packages/contracts/src/execution.ts @@ -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; +export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]); +export type TaskStatus = z.infer; + +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; +/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */ +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; +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; +/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */ +export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) }); +export type TaskCancelRequest = z.infer; + +export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]); +export type PlanPurpose = z.infer; +export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]); +export type PlanStatus = z.infer; + +/** 下载计划:与 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; +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; +export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial(); +export type AcquisitionPlanUpdate = z.infer; + +export const DownloadOrigin = z.enum(["submitted", "adopted"]); +export type DownloadOrigin = z.infer; +export const DownloadStatus = z.enum([ + "queued", + "submitting", + "downloading", + "paused", + "stalled", + "completed", + "importing", + "imported", + "failed", + "removed", + "missing", +]); +export type DownloadStatus = z.infer; +/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */ +export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]); +export type PauseReason = z.infer; +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; +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; +export const DownloadTaskUpdate = DownloadTask.omit({ + ...SERVER_MANAGED, + instanceId: true, + torrentHash: true, +}).partial(); +export type DownloadTaskUpdate = z.infer; + +export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]); +export type OperationPhase = z.infer; +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; +export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]); +export type OperationStatus = z.infer; + +/** 副作用账本: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; +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; +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; diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts new file mode 100644 index 00000000..7aef5b14 --- /dev/null +++ b/packages/contracts/src/identity.ts @@ -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; +export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]); +export type ExternalEntityType = z.infer; +/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */ +export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]); +export type EvidenceLevel = z.infer; +export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]); +export type IdentityVerifyStatus = z.infer; +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; + +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; + +/** 变更一律生成 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; + +export const IdentityRevisionAction = z.enum([ + "created", + "status_changed", + "relinked", + "evidence_updated", + "locked", + "unlocked", +]); +export type IdentityRevisionAction = z.infer; + +/** 身份修订历史:只追加,不修改 */ +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; +/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */ +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; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8bebbea1..a6aae240 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts new file mode 100644 index 00000000..1dca43ab --- /dev/null +++ b/packages/contracts/src/instances.ts @@ -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; + +export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]); +export type LibraryMediaKind = z.infer; + +/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */ +export const ManagementMode = z.enum(["register_only", "managed"]); +export type ManagementMode = z.infer; + +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; +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; +export const LibraryRootUpdate = LibraryRootCreate.partial(); +export type LibraryRootUpdate = z.infer; + +/** 只写凭证:明文仅在请求中出现,绝不回显 */ +const Credential = z.string().min(1).max(4096); + +export const DownloaderKind = z.enum(["qbittorrent"]); +export type DownloaderKind = z.infer; + +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; +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; +export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial(); +export type DownloaderInstanceUpdate = z.infer; + +export const MediaServerKind = z.enum(["emby", "jellyfin"]); +export type MediaServerKind = z.infer; + +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; +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; +export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial(); +export type MediaServerInstanceUpdate = z.infer; + +export const PathViewKind = z.enum(["downloader", "media_server"]); +export type PathViewKind = z.infer; +export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]); +export type PathMappingVerifyStatus = z.infer; + +/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(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; + +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; +export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, { + message: "viewKind 与实例 ID 不匹配", +}); +export type PathMappingUpdate = z.infer; diff --git a/packages/contracts/src/ops.ts b/packages/contracts/src/ops.ts new file mode 100644 index 00000000..8dfea48a --- /dev/null +++ b/packages/contracts/src/ops.ts @@ -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; +/** read_only:恢复后默认只读对账模式(REQ §8.5) */ +export const ReconcileMode = z.enum(["normal", "read_only"]); +export type ReconcileMode = z.infer; +export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]); +export type ReconcileStatus = z.infer; + +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; +export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({ + mode: true, + scope: true, +}); +export type ReconcileRunCreate = z.infer; +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; + +export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]); +export type ServerItemType = z.infer; +/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */ +export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]); +export type ServerMatchStatus = z.infer; + +/** 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; +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; +export const ServerMappingUpdate = ServerMappingCreate.omit({ + mediaServerInstanceId: true, + serverItemId: true, +}).partial(); +export type ServerMappingUpdate = z.infer; + +/** 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; +export const ApiKeyCreate = z.object({ + name: z.string().min(1).max(80), + scopes: z.array(ApiScope).min(1), +}); +export type ApiKeyCreate = z.infer; +/** 创建响应:明文 key 只在此返回一次 */ +export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) }); +export type ApiKeyCreated = z.infer; +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; + +/** 审计事件(只追加) */ +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; +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; + +export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]); +export type OutboxStatus = z.infer; +/** 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; +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; +export const OutboxEventUpdate = OutboxEvent.pick({ + status: true, + attempts: true, + nextAttemptAt: true, + lastError: true, + publishedAt: true, +}).partial(); +export type OutboxEventUpdate = z.infer; + +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; + +/** 加密凭证的元数据视图:密文/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; +/** 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; +export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) }); +export type SecretUpdate = z.infer; diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts new file mode 100644 index 00000000..1850da97 --- /dev/null +++ b/packages/contracts/src/organization.ts @@ -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; +export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]); +export type OrganizationStatus = z.infer; +/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */ +export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]); +export type OrganizationAction = z.infer; +/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */ +export const SourcePathView = z.enum(["downloader", "local", "library"]); +export type SourcePathView = z.infer; +export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]); +export type FileRole = z.infer; +export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]); +export type FileResultStatus = z.infer; + +/** 稳定变更游标:bigint 序号的十进制字符串 */ +export const ChangeSeq = z.number().int().nonnegative(); +export const ChangeCursor = z.string().regex(/^\d{1,16}$/); +export type ChangeCursor = z.infer; + +// ── 内部行表示(与 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; +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; +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; + +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; +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; +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; + +// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ── + +/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */ +export const PublicReason = z.object({ + code: z.string().min(1), + message: z.string(), +}); +export type PublicReason = z.infer; + +/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */ +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; + +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; + +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; + +/** 增量读取请求: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; + +/** + * 增量读取响应: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; + +/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */ +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; + +/** 手动发起整理(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; + +export const OrganizeAccepted = z.object({ + recordId: Id.nullable(), + taskId: Id, + planId: Id.nullable(), +}); +export type OrganizeAccepted = z.infer; diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts new file mode 100644 index 00000000..46a41460 --- /dev/null +++ b/packages/contracts/src/policy.ts @@ -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; +export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]); +export type PolicyStatus = z.infer; + +/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 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; +/** 新建总是草案;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; +/** 只有草案可改内容;发布/回滚走动作端点 */ +export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial(); +export type PolicyRevisionUpdate = z.infer; + +export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]); +export type SubscriptionMediaKind = z.infer; +export const SubscriptionMode = z.enum(["once", "continuous"]); +export type SubscriptionMode = z.infer; +/** 仅本季 / 持续后续季(REQ §6.5) */ +export const SeasonScope = z.enum(["current_season", "following_seasons"]); +export type SeasonScope = z.infer; +export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]); +export type SubscriptionOrigin = z.infer; +/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */ +export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]); +export type AcquisitionPriority = z.infer; +export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]); +export type SubscriptionStatus = z.infer; + +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; +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; +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; + +/** Slot 状态(REQ §6.1) */ +export const SlotStatus = z.enum([ + "empty", + "reserved", + "downloading", + "verifying", + "satisfied", + "upgrade_pending", + "attention", +]); +export type SlotStatus = z.infer; + +/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */ +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; +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; +/** 规则可改;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; + +/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */ +export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]); +export type PreferenceKind = z.infer; +export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]); +export type PreferenceOrigin = z.infer; +export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]); +export type PreferenceStatus = z.infer; + +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; +/** 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; +export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true }) + .partial() + .extend({ status: PreferenceStatus.optional() }); +export type PreferenceTagUpdate = z.infer; + +/** 手动取消标签留下的抑制记录: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; +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; +/** 唯一允许的更新是解除抑制 */ +export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() }); +export type PreferenceSuppressionUpdate = z.infer; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts new file mode 100644 index 00000000..6622de76 --- /dev/null +++ b/packages/contracts/src/settings.ts @@ -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; + +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; + +export const OrganizeMode = z.enum(["copy", "move"]); +export type OrganizeMode = z.infer; +export const OrganizeSettings = z.object({ + defaultMode: OrganizeMode, + /** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */ + allowCrossVolumeMove: z.boolean(), +}); +export type OrganizeSettings = z.infer; + +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; + +export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); +export type ThinkingLevel = z.infer; + +/** 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; + +/** 每日预算与并发(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; + +export const GlobalSettings = z.object({ + automation: AutomationSettings, + cache: CacheSettings, + organize: OrganizeSettings, + retention: RetentionSettings, + pi: PiSettings, + budget: BudgetSettings, +}); +export type GlobalSettings = z.infer; + +/** settings 表的合法 key */ +export const SettingsKey = GlobalSettings.keyof(); +export type SettingsKey = z.infer; + +/** 缺省设置(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; + +/** 合并部分更新(纯函数,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; diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts new file mode 100644 index 00000000..f31a7c90 --- /dev/null +++ b/packages/contracts/src/sources.ts @@ -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; +export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]); +export type SiteHealth = z.infer; +export const CircuitState = z.enum(["closed", "open", "half_open"]); +export type CircuitState = z.infer; + +/** 站点级限流(快照、实时搜索、浏览器共享) */ +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; + +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; +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; +export const SiteUpdate = SiteCreate.omit({ key: true }).partial(); +export type SiteUpdate = z.infer; + +export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]); +export type SiteAuthKind = z.infer; +export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]); +export type SiteAccountStatus = z.infer; + +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; +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; +export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial(); +export type SiteAccountUpdate = z.infer; + +export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]); +export type FeedPurpose = z.infer; +export const FeedKind = z.enum(["rss", "atom", "list", "query"]); +export type FeedKind = z.infer; +export const FeedHealth = z.enum(["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"]); +export type FeedHealth = z.infer; + +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; +/** 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; +export const FeedSourceUpdate = FeedSourceCreate.partial(); +export type FeedSourceUpdate = z.infer; + +export const ResolutionStatus = z.enum(["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"]); +export type ResolutionStatus = z.infer; +export const ReleaseAvailability = z.enum(["active", "site_disabled", "expired", "unknown"]); +export type ReleaseAvailability = z.infer; + +/** 促销状态必须带观察时间与过期时间,下单前按策略复核(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; + +export const TorrentFileEntry = z.object({ + path: z.string(), + size: NonNegInt, +}); +export type TorrentFileEntry = z.infer; + +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; +/** 由适配器/任务写入(按 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; +export const SourceReleaseUpdate = SourceReleaseCreate.omit({ siteId: true, siteReleaseId: true }).partial(); +export type SourceReleaseUpdate = z.infer; + +export const ObservationStatus = z.enum(["new", "linked", "ignored", "error"]); +export type ObservationStatus = z.infer; + +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; +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; +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; diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts new file mode 100644 index 00000000..52f7c088 --- /dev/null +++ b/packages/contracts/test/db-sync.test.ts @@ -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 { + const out = new Map(); + 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 { + const out = new Map(); + 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 = { + 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 = { + 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); + } + }); +}); diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts new file mode 100644 index 00000000..4f322131 --- /dev/null +++ b/packages/contracts/test/schemas.test.ts @@ -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; + options?: z.ZodType[]; + entries?: Record; + 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(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 = {}; + 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) => Record> = { + 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); + 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; + 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"); + }); +}); diff --git a/packages/db/README.md b/packages/db/README.md index c269689a..6df5aa04 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -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。 diff --git a/packages/db/migrations/0002_foundation.ts b/packages/db/migrations/0002_foundation.ts new file mode 100644 index 00000000..d048bcef --- /dev/null +++ b/packages/db/migrations/0002_foundation.ts @@ -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): Promise { + 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): Promise { + 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 不卸载 +`, + ); +} diff --git a/packages/db/migrations/0003_instances.ts b/packages/db/migrations/0003_instances.ts new file mode 100644 index 00000000..5a71ef6e --- /dev/null +++ b/packages/db/migrations/0003_instances.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["path_mapping", "media_server_instance", "downloader_instance", "library_root"])); +} diff --git a/packages/db/migrations/0004_content.ts b/packages/db/migrations/0004_content.ts new file mode 100644 index 00000000..7cdd4e70 --- /dev/null +++ b/packages/db/migrations/0004_content.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["title_alias", "content_unit", "media_entity"])); +} diff --git a/packages/db/migrations/0005_identity.ts b/packages/db/migrations/0005_identity.ts new file mode 100644 index 00000000..176a50eb --- /dev/null +++ b/packages/db/migrations/0005_identity.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["identity_revision", "external_identity"])); +} diff --git a/packages/db/migrations/0006_sources.ts b/packages/db/migrations/0006_sources.ts new file mode 100644 index 00000000..8de00a29 --- /dev/null +++ b/packages/db/migrations/0006_sources.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["observation", "source_release", "feed_source", "site_account", "site"])); +} diff --git a/packages/db/migrations/0007_assets.ts b/packages/db/migrations/0007_assets.ts new file mode 100644 index 00000000..4d6ffb77 --- /dev/null +++ b/packages/db/migrations/0007_assets.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["path_binding", "asset_location", "asset_unit_coverage", "media_asset"])); +} diff --git a/packages/db/migrations/0008_policy.ts b/packages/db/migrations/0008_policy.ts new file mode 100644 index 00000000..0fcb7b25 --- /dev/null +++ b/packages/db/migrations/0008_policy.ts @@ -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): Promise { + 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): Promise { + await execDdl( + db, + dropTables(["preference_suppression", "preference_tag", "acquisition_slot", "subscription", "policy_revision"]), + ); +} diff --git a/packages/db/migrations/0009_execution.ts b/packages/db/migrations/0009_execution.ts new file mode 100644 index 00000000..a1a06b37 --- /dev/null +++ b/packages/db/migrations/0009_execution.ts @@ -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): Promise { + 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): Promise { + 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"])} +`, + ); +} diff --git a/packages/db/migrations/0010_agent.ts b/packages/db/migrations/0010_agent.ts new file mode 100644 index 00000000..7df083b5 --- /dev/null +++ b/packages/db/migrations/0010_agent.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["automation_grant", "approval", "decision", "artifact", "agent_run"])); +} diff --git a/packages/db/migrations/0011_ops.ts b/packages/db/migrations/0011_ops.ts new file mode 100644 index 00000000..c88e82b4 --- /dev/null +++ b/packages/db/migrations/0011_ops.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, dropTables(["server_mapping", "reconcile_run"])); +} diff --git a/packages/db/migrations/0012_organization.ts b/packages/db/migrations/0012_organization.ts new file mode 100644 index 00000000..29cf1700 --- /dev/null +++ b/packages/db/migrations/0012_organization.ts @@ -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): Promise { + 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): Promise { + 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 列拥有,随表一起删除 +`, + ); +} diff --git a/packages/db/package.json b/packages/db/package.json index a0b670d2..184025e3 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/scripts/gen-types-lib.ts b/packages/db/scripts/gen-types-lib.ts new file mode 100644 index 00000000..e1256dbb --- /dev/null +++ b/packages/db/scripts/gen-types-lib.ts @@ -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 = { + 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): Promise { + const cols = await sql` + 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(); + 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(); + 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;", + "/** 有默认值的 timestamptz(Generated 不会展开嵌套 ColumnType,故单独定义) */", + "export type GeneratedTimestamptz = ColumnType;", + "/** 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)); diff --git a/packages/db/scripts/gen-types.ts b/packages/db/scripts/gen-types.ts new file mode 100644 index 00000000..4bc74d1b --- /dev/null +++ b/packages/db/scripts/gen-types.ts @@ -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(); +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 918839fd..cc33d300 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -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; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a30be791..8dbfbcb1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/src/migration-helpers.ts b/packages/db/src/migration-helpers.ts new file mode 100644 index 00000000..34837e09 --- /dev/null +++ b/packages/db/src/migration-helpers.ts @@ -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, ddl: string): Promise { + 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"); +} diff --git a/packages/db/src/migrator.ts b/packages/db/src/migrator.ts index 29d900ba..4a8e0a8e 100644 --- a/packages/db/src/migrator.ts +++ b/packages/db/src/migrator.ts @@ -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): Migrator { +export function createMigrator(db: Kysely, 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): Promise { - const result = await createMigrator(db).migrateToLatest(); +export async function migrateToLatest(db: Kysely, opts: MigratorOptions = {}): Promise { + const result = await createMigrator(db, opts).migrateToLatest(); if (result.error) throw result.error; return result; } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index c377f7fc..b30e7448 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1,57 +1,1134 @@ +// 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。 +// 改表:新增迁移 → pnpm db:migrate → pnpm --filter @umt/db gen:types。 + import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely"; -// 手写的表类型,与 migrations/ 保持同步;后续表多了再考虑 kysely-codegen +/** timestamptz:读出为 Date,写入可传 Date 或 ISO 串 */ +export type Timestamptz = ColumnType; +/** 有默认值的 timestamptz(Generated 不会展开嵌套 ColumnType,故单独定义) */ +export type GeneratedTimestamptz = ColumnType; +/** jsonb:写入数组时须先 JSON.stringify(pg 会把 JS 数组当作 PG 数组) */ +export type Json = unknown; -type Timestamptz = ColumnType; -type Json = ColumnType; +export type AcquisitionPlanPurpose = "fill" | "upgrade" | "replace_source"; +export type AcquisitionPlanStatus = + | "draft" + | "reserved" + | "submitted" + | "completed" + | "cancelled" + | "failed" + | "expired"; +export type AcquisitionPlanPriority = "immediate" | "tracking" | "new_work" | "upgrade"; +export type AcquisitionSlotStatus = + | "empty" + | "reserved" + | "downloading" + | "verifying" + | "satisfied" + | "upgrade_pending" + | "attention"; +export type AgentRunTrigger = "user" | "event" | "schedule" | "subagent"; +export type AgentRunStatus = + | "queued" + | "running" + | "waiting" + | "succeeded" + | "failed" + | "cancelled" + | "budget_exceeded"; +export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed"; +export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log"; +export type AssetLocationLocationKind = "library" | "download_cache" | "hardlink" | "external"; +export type AssetLocationStatus = "available" | "unavailable" | "suspected_missing" | "confirmed_missing" | "removed"; +export type AssetOperationPhase = "decision" | "prepare" | "transfer" | "verify" | "commit" | "cleanup" | "compensate"; +export type AssetOperationKind = + | "copy" + | "move" + | "rename" + | "mkdir" + | "hardlink" + | "delete_source" + | "delete_target" + | "verify" + | "register" + | "qb_pause" + | "qb_resume" + | "qb_remove" + | "qb_set_tags" + | "other"; +export type AssetOperationStatus = "planned" | "started" | "succeeded" | "failed" | "skipped" | "compensated"; +export type AssetUnitCoverageCoverage = "full" | "partial" | "segment" | "unknown"; +export type AssetUnitCoverageSegment = "full" | "upper" | "middle" | "lower" | "unknown"; +export type AutomationGrantStatus = "active" | "revoked" | "expired"; +export type ContentUnitKind = "season" | "episode" | "special" | "part"; +export type ContentUnitContentType = + | "main" + | "extra" + | "behind_the_scenes" + | "performance_only" + | "preview" + | "reaction" + | "unknown"; +export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown"; +export type ContentUnitStatus = "provisional" | "confirmed" | "retired"; +export type DecisionKind = + | "identity_ambiguous" + | "download_stalled" + | "download_failed" + | "path_conflict" + | "space_insufficient" + | "approval_required" + | "metadata_conflict" + | "source_unreachable" + | "other"; +export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired"; +export type DecisionSeverity = "info" | "warning" | "critical"; +export type DownloadTaskOrigin = "submitted" | "adopted"; +export type DownloadTaskStatus = + | "queued" + | "submitting" + | "downloading" + | "paused" + | "stalled" + | "completed" + | "importing" + | "imported" + | "failed" + | "removed" + | "missing"; +export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent"; +export type DownloaderInstanceKind = "qbittorrent"; +export type DownloaderInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline"; +export type ExternalIdentityEntityType = "movie" | "tv" | "season" | "episode" | "collection" | "person"; +export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired"; +export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review"; +export type ExternalIdentityVerifyStatus = "unverified" | "ok" | "not_found" | "type_mismatch" | "error"; +export type FeedSourcePurpose = "movie" | "series" | "variety" | "promotion" | "search" | "generic"; +export type FeedSourceKind = "rss" | "atom" | "list" | "query"; +export type FeedSourceHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "circuit_open" | "disabled"; +export type IdentityRevisionAction = + | "created" + | "status_changed" + | "relinked" + | "evidence_updated" + | "locked" + | "unlocked"; +export type IdentityRevisionFromStatus = "accepted" | "proposed" | "conflicted" | "retired"; +export type IdentityRevisionToStatus = "accepted" | "proposed" | "conflicted" | "retired"; +export type IdentityRevisionActorType = "user" | "agent" | "system" | "api_key"; +export type LibraryRootMediaKind = "movie" | "series" | "variety" | "mixed" | "other"; +export type LibraryRootManagementMode = "register_only" | "managed"; +export type LibraryRootHealthStatus = "unknown" | "healthy" | "degraded" | "offline"; +export type MediaAssetKind = "video" | "bluray_dir" | "subtitle" | "audio" | "nfo" | "image" | "other"; +export type MediaAssetStatus = "active" | "replaced" | "retired" | "missing"; +export type MediaEntityKind = "movie" | "series" | "variety" | "collection" | "other"; +export type MediaEntityStatus = "provisional" | "active" | "merged" | "archived"; +export type MediaServerInstanceKind = "emby" | "jellyfin"; +export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline"; +export type ObservationStatus = "new" | "linked" | "ignored" | "error"; +export type OrganizationFileResultRole = "main" | "subtitle" | "audio" | "bluray_dir" | "nfo" | "image" | "other"; +export type OrganizationFileResultMode = "copy" | "move" | "rename" | "relocate" | "hardlink"; +export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled"; +export type OrganizationRecordTrigger = "auto" | "manual" | "agent"; +export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key"; +export type OrganizationRecordSourcePathView = "downloader" | "local" | "library"; +export type OrganizationRecordAction = "copy" | "move" | "rename" | "relocate" | "hardlink"; +export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled"; +export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead"; +export type PathBindingBindingKind = "entity" | "season"; +export type PathBindingStatus = "active" | "conflict" | "superseded"; +export type PathMappingViewKind = "downloader" | "media_server"; +export type PathMappingVerifyStatus = "unverified" | "ok" | "failed"; +export type PolicyRevisionKind = "acquisition" | "preference" | "retention" | "cleanup" | "instance" | "automation"; +export type PolicyRevisionStatus = "draft" | "published" | "superseded" | "rolled_back" | "archived"; +export type PreferenceTagKind = "soft" | "hard" | "version" | "target"; +export type PreferenceTagOrigin = "manual" | "conversation" | "agent_inferred"; +export type PreferenceTagStatus = "active" | "disabled" | "deleted"; +export type ReconcileRunKind = "identity" | "metadata" | "asset" | "acquisition" | "server"; +export type ReconcileRunMode = "normal" | "read_only"; +export type ReconcileRunStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled"; +export type SecretPurpose = + | "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 ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other"; +export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale"; +export type SiteAdapter = "nexusphp" | "mteam_api" | "generic_rss"; +export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled"; +export type SiteCircuitState = "closed" | "open" | "half_open"; +export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password"; +export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired"; +export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected"; +export type SourceReleaseAvailability = "active" | "site_disabled" | "expired" | "unknown"; +export type SubscriptionMediaKind = "movie" | "series" | "variety"; +export type SubscriptionMode = "once" | "continuous"; +export type SubscriptionSeasonScope = "current_season" | "following_seasons"; +export type SubscriptionOrigin = "user" | "agent" | "policy" | "import"; +export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade"; +export type SubscriptionStatus = "active" | "paused" | "completed" | "cancelled"; +export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead"; +export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other"; +export type TitleAliasSource = "tmdb" | "user" | "site" | "agent" | "nfo" | "import"; -export interface SettingsTable { - key: string; - value: Json; - updated_at: Timestamptz; +export interface AcquisitionPlanTable { + id: Generated; + slot_id: string; + source_release_id: string; + task_id: string | null; + purpose: Generated; + status: Generated; + priority: Generated; + evaluation: Generated; + reserved_bytes: Generated; + budget_reservation: Generated; + payload_hash: string; + expected_revision: Generated; + idempotency_key: string; + expires_at: Timestamptz | null; + status_reason: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface AcquisitionSlotTable { + id: Generated; + media_entity_id: string; + content_unit_id: string | null; + subscription_id: string | null; + policy_revision_id: string | null; + name: string; + status: Generated; + hard_conditions: Generated; + soft_preferences: Generated; + fallback: Generated; + upgrade_threshold: Generated; + completion: Generated; + distinct_group: string | null; + require_distinct_asset: Generated; + current_asset_id: string | null; + status_reason: string | null; + last_evaluated_at: Timestamptz | null; + next_evaluate_at: Timestamptz | null; + revision: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; + reserved_plan_id: string | null; +} + +export interface AgentRunTable { + id: Generated; + task_id: string | null; + parent_run_id: string | null; + trigger: AgentRunTrigger; + goal: string; + status: Generated; + provider: string | null; + model: string | null; + thinking_level: string | null; + scope: Generated; + budget: Generated; + usage: Generated; + evidence_version: string | null; + session_state: Json | null; + summary: string | null; + tool_call_count: Generated; + last_event_seq: Generated; + error: Json | null; + cancel_requested_at: Timestamptz | null; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + created_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; } export interface ApiKeyTable { id: Generated; name: string; key_hash: string; - scopes: string[]; - created_at: Timestamptz; - revoked_at: Date | null; - last_used_at: Date | null; + scopes: Generated; + created_at: GeneratedTimestamptz; + revoked_at: Timestamptz | null; + last_used_at: Timestamptz | null; + updated_at: GeneratedTimestamptz; +} + +export interface ApprovalTable { + id: Generated; + plan_type: string; + plan_id: string; + payload_hash: string; + expected_revision: number; + scope: Generated; + status: Generated; + decision_id: string | null; + requested_by: string; + decided_by: string | null; + decided_at: Timestamptz | null; + expires_at: Timestamptz | null; + reason: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ArtifactTable { + id: Generated; + kind: ArtifactKind; + agent_run_id: string | null; + task_id: string | null; + subject_type: string | null; + subject_id: string | null; + title: string; + mime_type: Generated; + content: Generated; + content_hash: string; + size_bytes: number | null; + expires_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface AssetLocationTable { + id: Generated; + media_asset_id: string; + location_kind: Generated; + library_root_id: string | null; + downloader_instance_id: string | null; + relative_path: string; + status: Generated; + is_primary: Generated; + size_bytes: number | null; + inode: number | null; + device: number | null; + mtime: Timestamptz | null; + sidecar_of_location_id: string | null; + last_seen_at: Timestamptz | null; + last_checked_at: Timestamptz | null; + check_detail: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface AssetOperationTable { + id: Generated; + operation_id: string; + task_id: string | null; + plan_fingerprint: string | null; + ordinal: Generated; + phase: AssetOperationPhase; + kind: AssetOperationKind; + status: Generated; + intent: Generated; + attempt_token: string | null; + attempts: Generated; + result: Json | null; + error: Json | null; + media_asset_id: string | null; + download_task_id: string | null; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; + organization_record_id: string | null; +} + +export interface AssetUnitCoverageTable { + id: Generated; + media_asset_id: string; + content_unit_id: string; + coverage: Generated; + segment: AssetUnitCoverageSegment | null; + evidence: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; } export interface AuditEventTable { id: Generated; - at: Timestamptz; + at: GeneratedTimestamptz; actor_type: string; actor_id: string | null; action: string; target_type: string | null; target_id: string | null; - payload: Json; + payload: Generated; trace_id: string | null; } +export interface AutomationGrantTable { + id: Generated; + action: string; + policy_revision_id: string | null; + scope: Generated; + budget: Generated; + status: Generated; + granted_by: string; + reason: string | null; + expires_at: Timestamptz | null; + revoked_at: Timestamptz | null; + revoked_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ContentUnitTable { + id: Generated; + media_entity_id: string; + parent_unit_id: string | null; + kind: ContentUnitKind; + content_type: Generated; + segment: Generated; + season_number: number | null; + episode_number: number | null; + source_issue: string | null; + air_date: string | null; + display_order: number | null; + title: string | null; + status: Generated; + numbering_locked: Generated; + metadata: Generated; + field_locks: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface DecisionTable { + id: Generated; + kind: DecisionKind; + status: Generated; + severity: Generated; + title: string; + summary: string | null; + subject_type: string | null; + subject_id: string | null; + dedupe_key: string; + evidence: Generated; + options: Generated; + resolution: Json | null; + task_id: string | null; + agent_run_id: string | null; + attempts: Generated; + next_review_at: Timestamptz | null; + resolved_by: string | null; + resolved_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface DownloadTaskTable { + id: Generated; + instance_id: string; + torrent_hash: string; + infohash_v1: string | null; + infohash_v2: string | null; + acquisition_plan_id: string | null; + source_release_id: string | null; + name: string; + save_path: string | null; + content_path: string | null; + category: string | null; + tags: Generated; + managed: Generated; + origin: Generated; + status: Generated; + pause_reason: DownloadTaskPauseReason | null; + raw_state: string | null; + progress: Generated; + size_bytes: number | null; + downloaded_bytes: Generated; + remaining_bytes: number | null; + ratio: number | null; + seeding_time_sec: number | null; + completed_at: Timestamptz | null; + last_progress_at: Timestamptz | null; + no_progress_since: Timestamptz | null; + last_synced_at: Timestamptz | null; + next_check_at: Timestamptz | null; + removed_at: Timestamptz | null; + file_list: Json | null; + last_error: Json | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface DownloaderInstanceTable { + id: Generated; + name: string; + kind: Generated; + base_url: string; + username: string | null; + secret_ref: string | null; + managed_tag: Generated; + cache_volume_key: string | null; + enabled: Generated; + health_status: Generated; + last_seen_at: Timestamptz | null; + version_info: Generated; + settings: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ExternalIdentityTable { + id: Generated; + media_entity_id: string | null; + content_unit_id: string | null; + provider: string; + entity_type: ExternalIdentityEntityType; + external_id: string; + status: Generated; + evidence_level: Generated; + evidence: Generated; + locked: Generated; + revision: Generated; + valid_from: Timestamptz | null; + valid_until: Timestamptz | null; + verified_at: Timestamptz | null; + verify_status: Generated; + extra: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface FeedSourceTable { + id: Generated; + site_id: string | null; + site_account_id: string | null; + name: string; + purpose: Generated; + kind: Generated; + url_secret_ref: string | null; + url_display: string; + parser_version: Generated; + enabled: Generated; + poll_interval_sec: Generated; + filter: Generated; + cursor: Generated; + etag: string | null; + last_modified: string | null; + last_fetch_at: Timestamptz | null; + last_success_at: Timestamptz | null; + next_fetch_at: Timestamptz | null; + consecutive_failures: Generated; + backoff_until: Timestamptz | null; + health_status: Generated; + last_error: Json | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface IdentityRevisionTable { + id: Generated; + external_identity_id: string; + revision: number; + action: IdentityRevisionAction; + from_status: IdentityRevisionFromStatus | null; + to_status: IdentityRevisionToStatus | null; + before: Json | null; + after: Generated; + actor_type: IdentityRevisionActorType; + actor_id: string | null; + reason: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface LibraryRootTable { + id: Generated; + name: string; + media_kind: Generated; + local_path: string; + management_mode: Generated; + enabled: Generated; + sentinel_file: string | null; + health_status: Generated; + last_health_at: Timestamptz | null; + health_detail: Generated; + notes: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface MediaAssetTable { + id: Generated; + media_entity_id: string | null; + kind: MediaAssetKind; + parent_asset_id: string | null; + size_bytes: number | null; + content_hash: string | null; + hash_algorithm: string | null; + sample_digest: string | null; + media_info: Generated; + delivery: Generated; + content_edition: string | null; + source_release_id: string | null; + status: Generated; + verified_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; + download_task_id: string | null; +} + +export interface MediaEntityTable { + id: Generated; + kind: MediaEntityKind; + title: string; + original_title: string | null; + year: number | null; + status: Generated; + merged_into_id: string | null; + metadata: Generated; + field_locks: Generated; + last_metadata_refresh_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface MediaServerInstanceTable { + id: Generated; + name: string; + kind: Generated; + base_url: string; + user_id: string | null; + secret_ref: string | null; + enabled: Generated; + health_status: Generated; + last_seen_at: Timestamptz | null; + version_info: Generated; + settings: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ObservationTable { + id: Generated; + feed_id: string; + guid: string; + content_digest: string; + source_release_id: string | null; + title: string; + link_redacted: string | null; + raw: Generated; + source_published_at: Timestamptz | null; + fetched_at: GeneratedTimestamptz; + first_seen_at: GeneratedTimestamptz; + last_seen_at: GeneratedTimestamptz; + status: Generated; + resolve_attempts: Generated; + next_resolve_at: Timestamptz | null; + last_error: Json | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface OrganizationFileResultTable { + id: Generated; + organization_record_id: string; + ordinal: Generated; + asset_operation_id: string | null; + media_asset_id: string | null; + content_unit_id: string | null; + role: Generated; + mode: OrganizationFileResultMode; + source_path: string; + source_size_bytes: number | null; + target_library_root_id: string | null; + target_path: string | null; + status: Generated; + skip_reason: string | null; + error: Json | null; + checksum: string | null; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface OrganizationRecordTable { + id: Generated; + task_id: string | null; + trigger: OrganizationRecordTrigger; + actor_type: OrganizationRecordActorType; + actor_id: string | null; + media_entity_id: string | null; + content_unit_id: string | null; + download_task_id: string | null; + source_downloader_instance_id: string | null; + source_path_view: Generated; + source_path: string | null; + target_library_root_id: string | null; + target_path: string | null; + action: OrganizationRecordAction; + status: Generated; + reason: string | null; + error: Json | null; + plan: Generated; + plan_fingerprint: string | null; + file_total: Generated; + file_succeeded: Generated; + file_failed: Generated; + file_skipped: Generated; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + change_seq: Generated; + change_xid: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface OutboxEventTable { id: Generated; - at: Timestamptz; + at: GeneratedTimestamptz; type: string; - payload: Json; - published_at: Date | null; + payload: Generated; + published_at: Timestamptz | null; + idempotency_key: string | null; + status: Generated; + attempts: Generated; + next_attempt_at: GeneratedTimestamptz; + last_error: Json | null; +} + +export interface PathBindingTable { + id: Generated; + media_entity_id: string; + content_unit_id: string | null; + season_number: number | null; + binding_kind: PathBindingBindingKind; + library_root_id: string; + relative_path: string; + template_version: string | null; + status: Generated; + bound_at: GeneratedTimestamptz; + bound_by: string | null; + superseded_by_id: string | null; + note: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface PathMappingTable { + id: Generated; + view_kind: PathMappingViewKind; + downloader_instance_id: string | null; + media_server_instance_id: string | null; + library_root_id: string | null; + remote_prefix: string; + local_prefix: string; + priority: Generated; + enabled: Generated; + verify_status: Generated; + verified_at: Timestamptz | null; + verify_detail: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface PolicyRevisionTable { + id: Generated; + policy_key: string; + revision: number; + kind: PolicyRevisionKind; + status: Generated; + title: string; + summary: string | null; + scope: Generated; + rules: Generated; + preview: Json | null; + based_on_revision_id: string | null; + created_by: string | null; + published_at: Timestamptz | null; + published_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface PreferenceSuppressionTable { + id: Generated; + preference_tag_id: string | null; + fingerprint: string; + reason: string | null; + evidence: Generated; + suppressed_by: string; + active: Generated; + lifted_at: Timestamptz | null; + lifted_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface PreferenceTagTable { + id: Generated; + label: string; + kind: PreferenceTagKind; + scope: Generated; + rule: Generated; + fingerprint: string; + origin: Generated; + description: string | null; + status: Generated; + revision: Generated; + evidence: Generated; + policy_revision_id: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ReconcileRunTable { + id: Generated; + kind: ReconcileRunKind; + mode: Generated; + status: Generated; + scope: Generated; + resume_cursor: Generated; + stats: Generated; + task_id: string | null; + error: Json | null; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SecretTable { + id: Generated; + name: string; + purpose: SecretPurpose; + ciphertext: Buffer; + nonce: Buffer; + auth_tag: Buffer | null; + algorithm: Generated; + kdf: Generated; + kdf_params: Generated; + key_version: Generated; + rotated_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface ServerMappingTable { + id: Generated; + media_server_instance_id: string; + server_item_id: string; + item_type: ServerMappingItemType; + media_entity_id: string | null; + content_unit_id: string | null; + media_asset_id: string | null; + server_path: string | null; + path_mapping_id: string | null; + match_status: Generated; + version_info: Generated; + evidence: Generated; + observed_at: GeneratedTimestamptz; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SettingsTable { + key: string; + value: Json; + updated_at: GeneratedTimestamptz; +} + +export interface SiteTable { + id: Generated; + key: string; + name: string; + adapter: SiteAdapter; + base_url: string; + enabled: Generated; + rate_limit: Generated; + config: Generated; + health_status: Generated; + circuit_state: Generated; + circuit_open_until: Timestamptz | null; + snapshot_enabled: Generated; + snapshot_interval_sec: number | null; + last_snapshot_at: Timestamptz | null; + snapshot_cursor: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SiteAccountTable { + id: Generated; + site_id: string; + label: string; + auth_kind: SiteAccountAuthKind; + secret_ref: string | null; + enabled: Generated; + status: Generated; + last_verified_at: Timestamptz | null; + user_info: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SourceReleaseTable { + id: Generated; + site_id: string; + site_release_id: string; + title: string; + subtitle: string | null; + category_raw: string | null; + tags_raw: Generated; + size_bytes: number | null; + infohash_v1: string | null; + infohash_v2: string | null; + file_list: Json | null; + promotion: Generated; + promotion_expires_at: Timestamptz | null; + seeders: number | null; + leechers: number | null; + source_published_at: Timestamptz | null; + first_seen_at: GeneratedTimestamptz; + last_seen_at: GeneratedTimestamptz; + detail_fetched_at: Timestamptz | null; + parsed: Generated; + parser_version: string | null; + media_entity_id: string | null; + content_unit_id: string | null; + resolution_status: Generated; + resolution_evidence: Generated; + availability: Generated; + raw: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SubscriptionTable { + id: Generated; + media_entity_id: string | null; + season_unit_id: string | null; + title_query: string | null; + media_kind: SubscriptionMediaKind; + mode: Generated; + season_scope: Generated; + origin: Generated; + priority: Generated; + status: Generated; + policy_revision_id: string | null; + params: Generated; + status_reason: string | null; + last_checked_at: Timestamptz | null; + created_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface TaskTable { + id: Generated; + kind: string; + handler_version: Generated; + idempotency_key: string; + status: Generated; + priority: Generated; + input: Generated; + output: Json | null; + subject_type: string | null; + subject_id: string | null; + parent_task_id: string | null; + engine_run_id: string | null; + attempts: Generated; + max_attempts: Generated; + next_run_at: GeneratedTimestamptz; + wait_reason: string | null; + cancel_requested_at: Timestamptz | null; + cancel_reason: string | null; + last_error: Json | null; + started_at: Timestamptz | null; + finished_at: Timestamptz | null; + triggered_by: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface TitleAliasTable { + id: Generated; + media_entity_id: string; + content_unit_id: string | null; + alias: string; + normalized: string; + language: string | null; + kind: Generated; + source: TitleAliasSource; + locked: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; } export interface DB { - settings: SettingsTable; + acquisition_plan: AcquisitionPlanTable; + acquisition_slot: AcquisitionSlotTable; + agent_run: AgentRunTable; api_key: ApiKeyTable; + approval: ApprovalTable; + artifact: ArtifactTable; + asset_location: AssetLocationTable; + asset_operation: AssetOperationTable; + asset_unit_coverage: AssetUnitCoverageTable; audit_event: AuditEventTable; + automation_grant: AutomationGrantTable; + content_unit: ContentUnitTable; + decision: DecisionTable; + download_task: DownloadTaskTable; + downloader_instance: DownloaderInstanceTable; + external_identity: ExternalIdentityTable; + feed_source: FeedSourceTable; + identity_revision: IdentityRevisionTable; + library_root: LibraryRootTable; + media_asset: MediaAssetTable; + media_entity: MediaEntityTable; + media_server_instance: MediaServerInstanceTable; + observation: ObservationTable; + organization_file_result: OrganizationFileResultTable; + organization_record: OrganizationRecordTable; outbox_event: OutboxEventTable; + path_binding: PathBindingTable; + path_mapping: PathMappingTable; + policy_revision: PolicyRevisionTable; + preference_suppression: PreferenceSuppressionTable; + preference_tag: PreferenceTagTable; + reconcile_run: ReconcileRunTable; + secret: SecretTable; + server_mapping: ServerMappingTable; + settings: SettingsTable; + site: SiteTable; + site_account: SiteAccountTable; + source_release: SourceReleaseTable; + subscription: SubscriptionTable; + task: TaskTable; + title_alias: TitleAliasTable; } +export type AcquisitionPlanRow = Selectable; +export type NewAcquisitionPlan = Insertable; +export type AcquisitionPlanUpdate = Updateable; +export type AcquisitionSlotRow = Selectable; +export type NewAcquisitionSlot = Insertable; +export type AcquisitionSlotUpdate = Updateable; +export type AgentRunRow = Selectable; +export type NewAgentRun = Insertable; +export type AgentRunUpdate = Updateable; export type ApiKeyRow = Selectable; export type NewApiKey = Insertable; export type ApiKeyUpdate = Updateable; +export type ApprovalRow = Selectable; +export type NewApproval = Insertable; +export type ApprovalUpdate = Updateable; +export type ArtifactRow = Selectable; +export type NewArtifact = Insertable; +export type ArtifactUpdate = Updateable; +export type AssetLocationRow = Selectable; +export type NewAssetLocation = Insertable; +export type AssetLocationUpdate = Updateable; +export type AssetOperationRow = Selectable; +export type NewAssetOperation = Insertable; +export type AssetOperationUpdate = Updateable; +export type AssetUnitCoverageRow = Selectable; +export type NewAssetUnitCoverage = Insertable; +export type AssetUnitCoverageUpdate = Updateable; export type AuditEventRow = Selectable; export type NewAuditEvent = Insertable; +export type AuditEventUpdate = Updateable; +export type AutomationGrantRow = Selectable; +export type NewAutomationGrant = Insertable; +export type AutomationGrantUpdate = Updateable; +export type ContentUnitRow = Selectable; +export type NewContentUnit = Insertable; +export type ContentUnitUpdate = Updateable; +export type DecisionRow = Selectable; +export type NewDecision = Insertable; +export type DecisionUpdate = Updateable; +export type DownloadTaskRow = Selectable; +export type NewDownloadTask = Insertable; +export type DownloadTaskUpdate = Updateable; +export type DownloaderInstanceRow = Selectable; +export type NewDownloaderInstance = Insertable; +export type DownloaderInstanceUpdate = Updateable; +export type ExternalIdentityRow = Selectable; +export type NewExternalIdentity = Insertable; +export type ExternalIdentityUpdate = Updateable; +export type FeedSourceRow = Selectable; +export type NewFeedSource = Insertable; +export type FeedSourceUpdate = Updateable; +export type IdentityRevisionRow = Selectable; +export type NewIdentityRevision = Insertable; +export type IdentityRevisionUpdate = Updateable; +export type LibraryRootRow = Selectable; +export type NewLibraryRoot = Insertable; +export type LibraryRootUpdate = Updateable; +export type MediaAssetRow = Selectable; +export type NewMediaAsset = Insertable; +export type MediaAssetUpdate = Updateable; +export type MediaEntityRow = Selectable; +export type NewMediaEntity = Insertable; +export type MediaEntityUpdate = Updateable; +export type MediaServerInstanceRow = Selectable; +export type NewMediaServerInstance = Insertable; +export type MediaServerInstanceUpdate = Updateable; +export type ObservationRow = Selectable; +export type NewObservation = Insertable; +export type ObservationUpdate = Updateable; +export type OrganizationFileResultRow = Selectable; +export type NewOrganizationFileResult = Insertable; +export type OrganizationFileResultUpdate = Updateable; +export type OrganizationRecordRow = Selectable; +export type NewOrganizationRecord = Insertable; +export type OrganizationRecordUpdate = Updateable; export type OutboxEventRow = Selectable; +export type NewOutboxEvent = Insertable; +export type OutboxEventUpdate = Updateable; +export type PathBindingRow = Selectable; +export type NewPathBinding = Insertable; +export type PathBindingUpdate = Updateable; +export type PathMappingRow = Selectable; +export type NewPathMapping = Insertable; +export type PathMappingUpdate = Updateable; +export type PolicyRevisionRow = Selectable; +export type NewPolicyRevision = Insertable; +export type PolicyRevisionUpdate = Updateable; +export type PreferenceSuppressionRow = Selectable; +export type NewPreferenceSuppression = Insertable; +export type PreferenceSuppressionUpdate = Updateable; +export type PreferenceTagRow = Selectable; +export type NewPreferenceTag = Insertable; +export type PreferenceTagUpdate = Updateable; +export type ReconcileRunRow = Selectable; +export type NewReconcileRun = Insertable; +export type ReconcileRunUpdate = Updateable; +export type SecretRow = Selectable; +export type NewSecret = Insertable; +export type SecretUpdate = Updateable; +export type ServerMappingRow = Selectable; +export type NewServerMapping = Insertable; +export type ServerMappingUpdate = Updateable; export type SettingsRow = Selectable; +export type NewSettings = Insertable; +export type SettingsUpdate = Updateable; +export type SiteRow = Selectable; +export type NewSite = Insertable; +export type SiteUpdate = Updateable; +export type SiteAccountRow = Selectable; +export type NewSiteAccount = Insertable; +export type SiteAccountUpdate = Updateable; +export type SourceReleaseRow = Selectable; +export type NewSourceRelease = Insertable; +export type SourceReleaseUpdate = Updateable; +export type SubscriptionRow = Selectable; +export type NewSubscription = Insertable; +export type SubscriptionUpdate = Updateable; +export type TaskRow = Selectable; +export type NewTask = Insertable; +export type TaskUpdate = Updateable; +export type TitleAliasRow = Selectable; +export type NewTitleAlias = Insertable; +export type TitleAliasUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts new file mode 100644 index 00000000..313faee8 --- /dev/null +++ b/packages/db/test/schema.test.ts @@ -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; + let db: Kysely; + + 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({ + 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); + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index ade0f220..c17855df 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + // 迁移文件由 Kysely FileMigrationProvider 用原生 import() 加载(Node 类型剥离,不做无扩展名解析), + // 因此 migrations/ 内相对导入必须写 .ts 扩展名 + "allowImportingTsExtensions": true + }, "include": ["src", "test", "scripts", "migrations"] }