feat(db,contracts): full v0.1 data model migrations and shared zod contracts (W1-1)
- migrations 0002-0012 split by domain (foundation/instances/content/identity/ sources/assets/policy/execution/agent/ops/organization): 41 tables, uuid pk, created_at/updated_at + touch trigger, text+CHECK status sets, pgcrypto/pg_trgm - key constraints: source_release(site_id, site_release_id), observation(feed_id, guid), external_identity accepted partial unique, download_task(instance_id, torrent_hash), one active acquisition_plan per slot, one active path_binding per entity/season, asset_location root+path uniqueness, organization_record change_seq/change_xid cursor - secret table (ciphertext/nonce/kdf) + secret_ref columns; outbox dispatch fields (ADR-002) - src/types.ts generated from the migrated schema (scripts/gen-types.ts), drift-checked in tests; int8 -> number, date -> string parsers; migrator accepts migrationTableSchema - contracts: Row/Create/Update schemas per domain, public organization-record API (changeSeq cursor), GlobalSettings, CapabilityDescriptor + 14 capability IO schemas, typed SSE events, Api endpoint table - tests: migration up/down round trip on real PG in an isolated schema, constraint checks, contracts<->db column/enum sync, positive/negative parse for every exported schema Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
7ba712ea55
commit
cc8b3a7df4
42 files changed
+7026
-25
No files matched your search
@@ -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 里仍会填默认值);缺省值放常量。
|
||||
@@ -0,0 +1,284 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 人工/AI 域:Agent 运行、证据/计划产物、待处理事项、审批、持续授权(REQ §10、§11.3、§11.4)
|
||||
|
||||
export const AgentTrigger = z.enum(["user", "event", "schedule", "subagent"]);
|
||||
export type AgentTrigger = z.infer<typeof AgentTrigger>;
|
||||
export const AgentRunStatus = z.enum([
|
||||
"queued",
|
||||
"running",
|
||||
"waiting",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"budget_exceeded",
|
||||
]);
|
||||
export type AgentRunStatus = z.infer<typeof AgentRunStatus>;
|
||||
|
||||
/** 单次运行预算(调用次数/token/时长/费用,REQ §10.5) */
|
||||
export const AgentBudget = z.object({
|
||||
maxToolCalls: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
maxDurationSec: z.number().int().positive().optional(),
|
||||
maxCostUsd: z.number().nonnegative().optional(),
|
||||
});
|
||||
export type AgentBudget = z.infer<typeof AgentBudget>;
|
||||
|
||||
export const AgentUsage = z.object({
|
||||
inputTokens: NonNegInt.optional(),
|
||||
outputTokens: NonNegInt.optional(),
|
||||
cacheReadTokens: NonNegInt.optional(),
|
||||
cacheWriteTokens: NonNegInt.optional(),
|
||||
costUsd: z.number().nonnegative().optional(),
|
||||
durationMs: NonNegInt.optional(),
|
||||
});
|
||||
export type AgentUsage = z.infer<typeof AgentUsage>;
|
||||
|
||||
export const AgentRun = EntityBase.extend({
|
||||
taskId: Id.nullable(),
|
||||
parentRunId: Id.nullable(),
|
||||
trigger: AgentTrigger,
|
||||
goal: z.string().min(1),
|
||||
status: AgentRunStatus,
|
||||
provider: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
thinkingLevel: z.string().nullable(),
|
||||
/** 授权范围(能力白名单、对象范围);子代理继承父任务上限 */
|
||||
scope: JsonObject,
|
||||
budget: AgentBudget,
|
||||
usage: AgentUsage,
|
||||
evidenceVersion: z.string().nullable(),
|
||||
/** Pi 会话持久化状态;不对普通列表返回 */
|
||||
sessionState: JsonObject.nullable(),
|
||||
summary: z.string().nullable(),
|
||||
toolCallCount: NonNegInt,
|
||||
lastEventSeq: NonNegInt,
|
||||
error: JsonObject.nullable(),
|
||||
cancelRequestedAt: Timestamp.nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
});
|
||||
export type AgentRun = z.infer<typeof AgentRun>;
|
||||
export const AgentRunCreate = AgentRun.pick({
|
||||
taskId: true,
|
||||
parentRunId: true,
|
||||
trigger: true,
|
||||
goal: true,
|
||||
scope: true,
|
||||
budget: true,
|
||||
}).partial({ taskId: true, parentRunId: true, trigger: true, scope: true, budget: true });
|
||||
export type AgentRunCreate = z.infer<typeof AgentRunCreate>;
|
||||
export const AgentRunUpdate = AgentRun.pick({
|
||||
status: true,
|
||||
provider: true,
|
||||
model: true,
|
||||
thinkingLevel: true,
|
||||
usage: true,
|
||||
evidenceVersion: true,
|
||||
sessionState: true,
|
||||
summary: true,
|
||||
toolCallCount: true,
|
||||
lastEventSeq: true,
|
||||
error: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type AgentRunUpdate = z.infer<typeof AgentRunUpdate>;
|
||||
|
||||
export const ArtifactKind = z.enum(["evidence", "plan", "proposal", "report", "snapshot", "log"]);
|
||||
export type ArtifactKind = z.infer<typeof ArtifactKind>;
|
||||
|
||||
/** 证据/计划/提案产物:提案不直接晋升为事实 */
|
||||
export const Artifact = EntityBase.extend({
|
||||
kind: ArtifactKind,
|
||||
agentRunId: Id.nullable(),
|
||||
taskId: Id.nullable(),
|
||||
subjectType: z.string().nullable(),
|
||||
subjectId: Id.nullable(),
|
||||
title: z.string().min(1),
|
||||
mimeType: z.string(),
|
||||
content: JsonObject,
|
||||
contentHash: z.string().min(1),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
expiresAt: Timestamp.nullable(),
|
||||
});
|
||||
export type Artifact = z.infer<typeof Artifact>;
|
||||
/** contentHash / sizeBytes 由服务端计算 */
|
||||
export const ArtifactCreate = Artifact.omit({ ...SERVER_MANAGED, contentHash: true, sizeBytes: true }).partial({
|
||||
agentRunId: true,
|
||||
taskId: true,
|
||||
subjectType: true,
|
||||
subjectId: true,
|
||||
mimeType: true,
|
||||
expiresAt: true,
|
||||
});
|
||||
export type ArtifactCreate = z.infer<typeof ArtifactCreate>;
|
||||
/** 产物内容不可变;只能调整过期时间 */
|
||||
export const ArtifactUpdate = z.object({ expiresAt: Timestamp.nullable() });
|
||||
export type ArtifactUpdate = z.infer<typeof ArtifactUpdate>;
|
||||
|
||||
export const DecisionKind = z.enum([
|
||||
"identity_ambiguous",
|
||||
"download_stalled",
|
||||
"download_failed",
|
||||
"path_conflict",
|
||||
"space_insufficient",
|
||||
"approval_required",
|
||||
"metadata_conflict",
|
||||
"source_unreachable",
|
||||
"other",
|
||||
]);
|
||||
export type DecisionKind = z.infer<typeof DecisionKind>;
|
||||
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
||||
export type DecisionStatus = z.infer<typeof DecisionStatus>;
|
||||
export const Severity = z.enum(["info", "warning", "critical"]);
|
||||
export type Severity = z.infer<typeof Severity>;
|
||||
|
||||
export const DecisionOption = z.object({
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
/** 选择后执行的能力与参数(仍经授权校验) */
|
||||
capabilityId: z.string().optional(),
|
||||
input: JsonObject.optional(),
|
||||
});
|
||||
export type DecisionOption = z.infer<typeof DecisionOption>;
|
||||
|
||||
/** 待处理中心条目;同一 dedupeKey 同时只有一个未关闭条目 */
|
||||
export const Decision = EntityBase.extend({
|
||||
kind: DecisionKind,
|
||||
status: DecisionStatus,
|
||||
severity: Severity,
|
||||
title: z.string().min(1),
|
||||
summary: z.string().nullable(),
|
||||
subjectType: z.string().nullable(),
|
||||
subjectId: Id.nullable(),
|
||||
dedupeKey: z.string().min(1),
|
||||
evidence: JsonObject,
|
||||
options: z.array(DecisionOption),
|
||||
resolution: JsonObject.nullable(),
|
||||
taskId: Id.nullable(),
|
||||
agentRunId: Id.nullable(),
|
||||
attempts: NonNegInt,
|
||||
nextReviewAt: Timestamp.nullable(),
|
||||
resolvedBy: z.string().nullable(),
|
||||
resolvedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type Decision = z.infer<typeof Decision>;
|
||||
export const DecisionCreate = Decision.pick({
|
||||
kind: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
subjectType: true,
|
||||
subjectId: true,
|
||||
dedupeKey: true,
|
||||
evidence: true,
|
||||
options: true,
|
||||
taskId: true,
|
||||
agentRunId: true,
|
||||
nextReviewAt: true,
|
||||
}).partial({
|
||||
severity: true,
|
||||
summary: true,
|
||||
subjectType: true,
|
||||
subjectId: true,
|
||||
evidence: true,
|
||||
options: true,
|
||||
taskId: true,
|
||||
agentRunId: true,
|
||||
nextReviewAt: true,
|
||||
});
|
||||
export type DecisionCreate = z.infer<typeof DecisionCreate>;
|
||||
export const DecisionUpdate = Decision.pick({
|
||||
status: true,
|
||||
severity: true,
|
||||
summary: true,
|
||||
evidence: true,
|
||||
options: true,
|
||||
agentRunId: true,
|
||||
nextReviewAt: true,
|
||||
}).partial();
|
||||
export type DecisionUpdate = z.infer<typeof DecisionUpdate>;
|
||||
/** 用户处理待处理事项:选一个选项或关闭 */
|
||||
export const DecisionResolveInput = z.object({
|
||||
optionKey: z.string().min(1).optional(),
|
||||
dismiss: z.boolean().optional(),
|
||||
note: z.string().max(2000).optional(),
|
||||
});
|
||||
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
|
||||
|
||||
export const ApprovalStatus = z.enum(["pending", "approved", "rejected", "revoked", "expired", "consumed"]);
|
||||
export type ApprovalStatus = z.infer<typeof ApprovalStatus>;
|
||||
|
||||
/** 单次审批:绑定 planType+planId + payloadHash + expectedRevision;换候选/路径后旧审批失效 */
|
||||
export const Approval = EntityBase.extend({
|
||||
planType: z.string().min(1),
|
||||
planId: Id,
|
||||
payloadHash: z.string().min(1),
|
||||
expectedRevision: z.number().int().min(0),
|
||||
scope: JsonObject,
|
||||
status: ApprovalStatus,
|
||||
decisionId: Id.nullable(),
|
||||
requestedBy: z.string().min(1),
|
||||
decidedBy: z.string().nullable(),
|
||||
decidedAt: Timestamp.nullable(),
|
||||
expiresAt: Timestamp.nullable(),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type Approval = z.infer<typeof Approval>;
|
||||
export const ApprovalCreate = Approval.pick({
|
||||
planType: true,
|
||||
planId: true,
|
||||
payloadHash: true,
|
||||
expectedRevision: true,
|
||||
scope: true,
|
||||
decisionId: true,
|
||||
requestedBy: true,
|
||||
expiresAt: true,
|
||||
reason: true,
|
||||
}).partial({ scope: true, decisionId: true, expiresAt: true, reason: true });
|
||||
export type ApprovalCreate = z.infer<typeof ApprovalCreate>;
|
||||
/** 批准/拒绝时回传 payloadHash,服务端比对防止审批被挪用到变更后的计划 */
|
||||
export const ApprovalUpdate = z.object({
|
||||
status: z.enum(["approved", "rejected", "revoked"]),
|
||||
payloadHash: z.string().min(1),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type ApprovalUpdate = z.infer<typeof ApprovalUpdate>;
|
||||
|
||||
export const GrantStatus = z.enum(["active", "revoked", "expired"]);
|
||||
export type GrantStatus = z.infer<typeof GrantStatus>;
|
||||
|
||||
/** 持续授权:绑定策略版本、动作、对象范围与预算 */
|
||||
export const AutomationGrant = EntityBase.extend({
|
||||
action: z.string().min(1),
|
||||
policyRevisionId: Id.nullable(),
|
||||
scope: JsonObject,
|
||||
budget: JsonObject,
|
||||
status: GrantStatus,
|
||||
grantedBy: z.string().min(1),
|
||||
reason: z.string().nullable(),
|
||||
expiresAt: Timestamp.nullable(),
|
||||
revokedAt: Timestamp.nullable(),
|
||||
revokedBy: z.string().nullable(),
|
||||
});
|
||||
export type AutomationGrant = z.infer<typeof AutomationGrant>;
|
||||
export const AutomationGrantCreate = AutomationGrant.pick({
|
||||
action: true,
|
||||
policyRevisionId: true,
|
||||
scope: true,
|
||||
budget: true,
|
||||
reason: true,
|
||||
expiresAt: true,
|
||||
}).partial({ policyRevisionId: true, scope: true, budget: true, reason: true, expiresAt: true });
|
||||
export type AutomationGrantCreate = z.infer<typeof AutomationGrantCreate>;
|
||||
/** 只能撤销或缩短有效期,不能就地扩大范围(扩大 = 新授权) */
|
||||
export const AutomationGrantUpdate = z.object({
|
||||
status: z.literal("revoked").optional(),
|
||||
expiresAt: Timestamp.nullable().optional(),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type AutomationGrantUpdate = z.infer<typeof AutomationGrantUpdate>;
|
||||
@@ -0,0 +1,783 @@
|
||||
import { z } from "zod";
|
||||
import * as agent from "./agent";
|
||||
import * as assets from "./assets";
|
||||
import { CapabilityDescriptor, KnownCapabilityId } from "./capability";
|
||||
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common";
|
||||
import * as content from "./content";
|
||||
import * as execution from "./execution";
|
||||
import * as identity from "./identity";
|
||||
import * as instances from "./instances";
|
||||
import * as ops from "./ops";
|
||||
import * as organization from "./organization";
|
||||
import * as policy from "./policy";
|
||||
import { GlobalSettings, GlobalSettingsUpdate } from "./settings";
|
||||
import * as sources from "./sources";
|
||||
|
||||
// 所有 REST 端点的路径常量与请求/响应 schema 汇总。
|
||||
// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用);
|
||||
// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。
|
||||
|
||||
export const API_PREFIX = "/api/v1";
|
||||
|
||||
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
|
||||
|
||||
export interface EndpointDef {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
scope: ApiScope;
|
||||
summary: string;
|
||||
params?: z.ZodObject;
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response: z.ZodType;
|
||||
}
|
||||
|
||||
const ep = <E extends EndpointDef>(e: E): E => e;
|
||||
|
||||
/** 列表查询:游标分页 + 资源自有过滤 */
|
||||
const listQuery = <S extends z.ZodRawShape>(filters: S) => CursorQuery.extend(filters);
|
||||
|
||||
/** 删除/无内容动作的响应 */
|
||||
export const OkResult = z.object({ ok: z.literal(true) });
|
||||
|
||||
interface CrudSchemas {
|
||||
row: z.ZodType;
|
||||
create?: z.ZodType;
|
||||
update?: z.ZodType;
|
||||
filters?: z.ZodRawShape;
|
||||
}
|
||||
|
||||
/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */
|
||||
function crud<const B extends string>(
|
||||
base: B,
|
||||
name: string,
|
||||
s: CrudSchemas,
|
||||
scopes: { read: ApiScope; write: ApiScope },
|
||||
) {
|
||||
const item = `${base}/{id}` as const;
|
||||
return {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: base,
|
||||
scope: scopes.read,
|
||||
summary: `${name}列表`,
|
||||
query: listQuery(s.filters ?? {}),
|
||||
response: cursorPage(s.row),
|
||||
}),
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: item,
|
||||
scope: scopes.read,
|
||||
summary: `${name}详情`,
|
||||
params: IdParam,
|
||||
response: s.row,
|
||||
}),
|
||||
...(s.create
|
||||
? {
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: base,
|
||||
scope: scopes.write,
|
||||
summary: `新建${name}`,
|
||||
body: s.create,
|
||||
response: s.row,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(s.update
|
||||
? {
|
||||
update: ep({
|
||||
method: "patch",
|
||||
path: item,
|
||||
scope: scopes.write,
|
||||
summary: `修改${name}`,
|
||||
params: IdParam,
|
||||
body: s.update,
|
||||
response: s.row,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const R = { read: "read", write: "admin" } as const;
|
||||
|
||||
const del = (path: string, summary: string) =>
|
||||
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
|
||||
|
||||
export const Api = {
|
||||
// ── 内容 / 身份 ──
|
||||
mediaEntities: {
|
||||
...crud(
|
||||
"/media-entities",
|
||||
"作品",
|
||||
{
|
||||
row: content.MediaEntity,
|
||||
create: content.MediaEntityCreate,
|
||||
update: content.MediaEntityUpdate,
|
||||
filters: {
|
||||
kind: content.MediaKind.optional(),
|
||||
status: content.MediaEntityStatus.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
merge: ep({
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/merge",
|
||||
scope: "admin",
|
||||
summary: "合并重复作品(保留旧 ID 跳转)",
|
||||
params: IdParam,
|
||||
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
|
||||
response: content.MediaEntity,
|
||||
}),
|
||||
},
|
||||
contentUnits: crud(
|
||||
"/content-units",
|
||||
"内容单元",
|
||||
{
|
||||
row: content.ContentUnit,
|
||||
create: content.ContentUnitCreate,
|
||||
update: content.ContentUnitUpdate,
|
||||
filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
titleAliases: {
|
||||
...crud(
|
||||
"/title-aliases",
|
||||
"别名",
|
||||
{
|
||||
row: content.TitleAlias,
|
||||
create: content.TitleAliasCreate,
|
||||
update: content.TitleAliasUpdate,
|
||||
filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
remove: del("/title-aliases/{id}", "删除别名"),
|
||||
},
|
||||
externalIdentities: {
|
||||
...crud(
|
||||
"/external-identities",
|
||||
"外部身份",
|
||||
{
|
||||
row: identity.ExternalIdentity,
|
||||
create: identity.ExternalIdentityCreate,
|
||||
update: identity.ExternalIdentityUpdate,
|
||||
filters: {
|
||||
mediaEntityId: Id.optional(),
|
||||
provider: z.string().optional(),
|
||||
status: identity.IdentityStatus.optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
revisions: ep({
|
||||
method: "get",
|
||||
path: "/external-identities/{id}/revisions",
|
||||
scope: "read",
|
||||
summary: "身份修订历史",
|
||||
params: IdParam,
|
||||
response: z.object({ items: z.array(identity.IdentityRevision) }),
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 文件 ──
|
||||
mediaAssets: crud(
|
||||
"/media-assets",
|
||||
"资产",
|
||||
{
|
||||
row: assets.MediaAsset,
|
||||
update: assets.MediaAssetUpdate,
|
||||
filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
assetUnitCoverage: crud(
|
||||
"/asset-unit-coverage",
|
||||
"资产覆盖",
|
||||
{
|
||||
row: assets.AssetUnitCoverage,
|
||||
create: assets.AssetUnitCoverageCreate,
|
||||
update: assets.AssetUnitCoverageUpdate,
|
||||
filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
assetLocations: crud(
|
||||
"/asset-locations",
|
||||
"资产位置",
|
||||
{
|
||||
row: assets.AssetLocation,
|
||||
update: assets.AssetLocationUpdate,
|
||||
filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
pathBindings: crud(
|
||||
"/path-bindings",
|
||||
"路径绑定",
|
||||
{
|
||||
row: assets.PathBinding,
|
||||
create: assets.PathBindingCreate,
|
||||
update: assets.PathBindingUpdate,
|
||||
filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
|
||||
// ── 实例 ──
|
||||
libraryRoots: crud(
|
||||
"/library-roots",
|
||||
"媒体库根",
|
||||
{
|
||||
row: instances.LibraryRoot,
|
||||
create: instances.LibraryRootCreate,
|
||||
update: instances.LibraryRootUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
downloaderInstances: {
|
||||
...crud(
|
||||
"/downloader-instances",
|
||||
"下载器实例",
|
||||
{
|
||||
row: instances.DownloaderInstance,
|
||||
create: instances.DownloaderInstanceCreate,
|
||||
update: instances.DownloaderInstanceUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
test: ep({
|
||||
method: "post",
|
||||
path: "/downloader-instances/{id}/test",
|
||||
scope: "admin",
|
||||
summary: "测试下载器连接",
|
||||
params: IdParam,
|
||||
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
|
||||
}),
|
||||
},
|
||||
mediaServerInstances: crud(
|
||||
"/media-server-instances",
|
||||
"媒体服务器实例",
|
||||
{
|
||||
row: instances.MediaServerInstance,
|
||||
create: instances.MediaServerInstanceCreate,
|
||||
update: instances.MediaServerInstanceUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
pathMappings: {
|
||||
...crud(
|
||||
"/path-mappings",
|
||||
"路径映射",
|
||||
{
|
||||
row: instances.PathMapping,
|
||||
create: instances.PathMappingCreate,
|
||||
update: instances.PathMappingUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
verify: ep({
|
||||
method: "post",
|
||||
path: "/path-mappings/{id}/verify",
|
||||
scope: "admin",
|
||||
summary: "校验路径映射可读写与挂载健康",
|
||||
params: IdParam,
|
||||
response: instances.PathMapping,
|
||||
}),
|
||||
remove: del("/path-mappings/{id}", "删除路径映射"),
|
||||
},
|
||||
|
||||
// ── 来源 ──
|
||||
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
|
||||
siteAccounts: crud(
|
||||
"/site-accounts",
|
||||
"站点账号",
|
||||
{
|
||||
row: sources.SiteAccount,
|
||||
create: sources.SiteAccountCreate,
|
||||
update: sources.SiteAccountUpdate,
|
||||
filters: { siteId: Id.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
feedSources: {
|
||||
...crud(
|
||||
"/feed-sources",
|
||||
"Feed",
|
||||
{
|
||||
row: sources.FeedSource,
|
||||
create: sources.FeedSourceCreate,
|
||||
update: sources.FeedSourceUpdate,
|
||||
filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
fetchNow: ep({
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/fetch",
|
||||
scope: "admin",
|
||||
summary: "立即抓取(仍受站点限流)",
|
||||
params: IdParam,
|
||||
response: z.object({ taskId: Id }),
|
||||
}),
|
||||
},
|
||||
sourceReleases: crud(
|
||||
"/source-releases",
|
||||
"站点资源",
|
||||
{
|
||||
row: sources.SourceRelease,
|
||||
filters: {
|
||||
siteId: Id.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
resolutionStatus: sources.ResolutionStatus.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
observations: crud(
|
||||
"/observations",
|
||||
"观察",
|
||||
{
|
||||
row: sources.Observation,
|
||||
filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
|
||||
// ── 规则 ──
|
||||
subscriptions: crud(
|
||||
"/subscriptions",
|
||||
"订阅",
|
||||
{
|
||||
row: policy.Subscription,
|
||||
create: policy.SubscriptionCreate,
|
||||
update: policy.SubscriptionUpdate,
|
||||
filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
policyRevisions: {
|
||||
...crud(
|
||||
"/policy-revisions",
|
||||
"策略版本",
|
||||
{
|
||||
row: policy.PolicyRevision,
|
||||
create: policy.PolicyRevisionCreate,
|
||||
update: policy.PolicyRevisionUpdate,
|
||||
filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
preview: ep({
|
||||
method: "post",
|
||||
path: "/policy-revisions/{id}/preview",
|
||||
scope: "read",
|
||||
summary: "历史回放预览",
|
||||
params: IdParam,
|
||||
response: policy.PolicyRevision,
|
||||
}),
|
||||
publish: ep({
|
||||
method: "post",
|
||||
path: "/policy-revisions/{id}/publish",
|
||||
scope: "admin",
|
||||
summary: "发布策略版本",
|
||||
params: IdParam,
|
||||
body: z.object({ expectedRevision: z.number().int().min(1) }),
|
||||
response: policy.PolicyRevision,
|
||||
}),
|
||||
rollback: ep({
|
||||
method: "post",
|
||||
path: "/policy-revisions/{id}/rollback",
|
||||
scope: "admin",
|
||||
summary: "回滚到该版本",
|
||||
params: IdParam,
|
||||
response: policy.PolicyRevision,
|
||||
}),
|
||||
},
|
||||
acquisitionSlots: crud(
|
||||
"/acquisition-slots",
|
||||
"收录目标",
|
||||
{
|
||||
row: policy.AcquisitionSlot,
|
||||
create: policy.AcquisitionSlotCreate,
|
||||
update: policy.AcquisitionSlotUpdate,
|
||||
filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
preferenceTags: {
|
||||
...crud(
|
||||
"/preference-tags",
|
||||
"偏好标签",
|
||||
{
|
||||
row: policy.PreferenceTag,
|
||||
create: policy.PreferenceTagCreate,
|
||||
update: policy.PreferenceTagUpdate,
|
||||
filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
/** 删除 = 标记 deleted + 写抑制记录 */
|
||||
remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"),
|
||||
},
|
||||
preferenceSuppressions: crud(
|
||||
"/preference-suppressions",
|
||||
"偏好抑制",
|
||||
{
|
||||
row: policy.PreferenceSuppression,
|
||||
create: policy.PreferenceSuppressionCreate,
|
||||
update: policy.PreferenceSuppressionUpdate,
|
||||
filters: { active: z.enum(["true", "false"]).optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
|
||||
// ── 执行 ──
|
||||
tasks: {
|
||||
...crud(
|
||||
"/tasks",
|
||||
"任务",
|
||||
{
|
||||
row: execution.Task,
|
||||
filters: {
|
||||
status: execution.TaskStatus.optional(),
|
||||
kind: execution.TaskKind.optional(),
|
||||
subjectId: Id.optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
cancel: ep({
|
||||
method: "post",
|
||||
path: "/tasks/{id}/cancel",
|
||||
scope: "organize.execute",
|
||||
summary: "取消任务(停止未来步骤)",
|
||||
params: IdParam,
|
||||
body: execution.TaskCancelRequest,
|
||||
response: execution.Task,
|
||||
}),
|
||||
retry: ep({
|
||||
method: "post",
|
||||
path: "/tasks/{id}/retry",
|
||||
scope: "organize.execute",
|
||||
summary: "重试失败任务",
|
||||
params: IdParam,
|
||||
response: execution.Task,
|
||||
}),
|
||||
operations: ep({
|
||||
method: "get",
|
||||
path: "/tasks/{id}/operations",
|
||||
scope: "read",
|
||||
summary: "任务的操作账本(业务时间线)",
|
||||
params: IdParam,
|
||||
response: z.object({ items: z.array(execution.AssetOperation) }),
|
||||
}),
|
||||
},
|
||||
acquisitionPlans: crud(
|
||||
"/acquisition-plans",
|
||||
"下载计划",
|
||||
{
|
||||
row: execution.AcquisitionPlan,
|
||||
filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
downloadTasks: {
|
||||
...crud(
|
||||
"/download-tasks",
|
||||
"下载任务",
|
||||
{
|
||||
row: execution.DownloadTask,
|
||||
filters: {
|
||||
instanceId: Id.optional(),
|
||||
status: execution.DownloadStatus.optional(),
|
||||
managed: z.enum(["true", "false"]).optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
pause: ep({
|
||||
method: "post",
|
||||
path: "/download-tasks/{id}/pause",
|
||||
scope: "organize.execute",
|
||||
summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)",
|
||||
params: IdParam,
|
||||
response: execution.DownloadTask,
|
||||
}),
|
||||
resume: ep({
|
||||
method: "post",
|
||||
path: "/download-tasks/{id}/resume",
|
||||
scope: "organize.execute",
|
||||
summary: "恢复下载",
|
||||
params: IdParam,
|
||||
response: execution.DownloadTask,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 人工 / AI ──
|
||||
decisions: {
|
||||
...crud(
|
||||
"/decisions",
|
||||
"待处理事项",
|
||||
{
|
||||
row: agent.Decision,
|
||||
filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
resolve: ep({
|
||||
method: "post",
|
||||
path: "/decisions/{id}/resolve",
|
||||
scope: "organize.execute",
|
||||
summary: "处理待处理事项",
|
||||
params: IdParam,
|
||||
body: agent.DecisionResolveInput,
|
||||
response: agent.Decision,
|
||||
}),
|
||||
},
|
||||
approvals: crud(
|
||||
"/approvals",
|
||||
"审批",
|
||||
{
|
||||
row: agent.Approval,
|
||||
update: agent.ApprovalUpdate,
|
||||
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
|
||||
},
|
||||
{ read: "read", write: "organize.execute" },
|
||||
),
|
||||
automationGrants: crud(
|
||||
"/automation-grants",
|
||||
"持续授权",
|
||||
{
|
||||
row: agent.AutomationGrant,
|
||||
create: agent.AutomationGrantCreate,
|
||||
update: agent.AutomationGrantUpdate,
|
||||
filters: { status: agent.GrantStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
agentRuns: {
|
||||
...crud(
|
||||
"/agent-runs",
|
||||
"Agent 运行",
|
||||
{
|
||||
row: agent.AgentRun,
|
||||
create: agent.AgentRunCreate,
|
||||
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
|
||||
},
|
||||
{ read: "read", write: "organize.execute" },
|
||||
),
|
||||
cancel: ep({
|
||||
method: "post",
|
||||
path: "/agent-runs/{id}/cancel",
|
||||
scope: "organize.execute",
|
||||
summary: "取消 Agent 运行",
|
||||
params: IdParam,
|
||||
response: agent.AgentRun,
|
||||
}),
|
||||
message: ep({
|
||||
method: "post",
|
||||
path: "/agent-runs/{id}/messages",
|
||||
scope: "organize.execute",
|
||||
summary: "向运行中的会话追加用户消息(纠正/补充)",
|
||||
params: IdParam,
|
||||
body: z.object({ text: z.string().min(1).max(8000) }),
|
||||
response: agent.AgentRun,
|
||||
}),
|
||||
},
|
||||
artifacts: crud(
|
||||
"/artifacts",
|
||||
"产物",
|
||||
{
|
||||
row: agent.Artifact,
|
||||
filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
|
||||
// ── 运维 ──
|
||||
reconcileRuns: crud(
|
||||
"/reconcile-runs",
|
||||
"校验运行",
|
||||
{
|
||||
row: ops.ReconcileRun,
|
||||
create: ops.ReconcileRunCreate,
|
||||
filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() },
|
||||
},
|
||||
R,
|
||||
),
|
||||
serverMappings: crud(
|
||||
"/server-mappings",
|
||||
"库存映射",
|
||||
{
|
||||
row: ops.ServerMapping,
|
||||
update: ops.ServerMappingUpdate,
|
||||
filters: {
|
||||
mediaServerInstanceId: Id.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
matchStatus: ops.ServerMatchStatus.optional(),
|
||||
},
|
||||
},
|
||||
R,
|
||||
),
|
||||
apiKeys: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/api-keys",
|
||||
scope: "admin",
|
||||
summary: "API Key 列表",
|
||||
query: CursorQuery,
|
||||
response: cursorPage(ops.ApiKey),
|
||||
}),
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: "/api-keys",
|
||||
scope: "admin",
|
||||
summary: "新建 API Key(明文只返回一次)",
|
||||
body: ops.ApiKeyCreate,
|
||||
response: ops.ApiKeyCreated,
|
||||
}),
|
||||
update: ep({
|
||||
method: "patch",
|
||||
path: "/api-keys/{id}",
|
||||
scope: "admin",
|
||||
summary: "修改/撤销 API Key",
|
||||
params: IdParam,
|
||||
body: ops.ApiKeyUpdate,
|
||||
response: ops.ApiKey,
|
||||
}),
|
||||
},
|
||||
auditEvents: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/audit-events",
|
||||
scope: "admin",
|
||||
summary: "审计事件",
|
||||
query: listQuery({
|
||||
targetType: z.string().optional(),
|
||||
targetId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
}),
|
||||
response: cursorPage(ops.AuditEvent),
|
||||
}),
|
||||
},
|
||||
secrets: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/secrets",
|
||||
scope: "admin",
|
||||
summary: "凭证元数据列表",
|
||||
query: CursorQuery,
|
||||
response: cursorPage(ops.Secret),
|
||||
}),
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: "/secrets",
|
||||
scope: "admin",
|
||||
summary: "新建凭证(只写)",
|
||||
body: ops.SecretCreate,
|
||||
response: ops.Secret,
|
||||
}),
|
||||
update: ep({
|
||||
method: "patch",
|
||||
path: "/secrets/{id}",
|
||||
scope: "admin",
|
||||
summary: "轮换凭证",
|
||||
params: IdParam,
|
||||
body: ops.SecretUpdate,
|
||||
response: ops.Secret,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 整理(公开合同) ──
|
||||
organization: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/organization-records",
|
||||
scope: "organize.read",
|
||||
summary: "整理记录列表",
|
||||
query: organization.OrganizationListQuery,
|
||||
response: cursorPage(organization.PublicOrganizationRecord),
|
||||
}),
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: "/organization-records/{id}",
|
||||
scope: "organize.read",
|
||||
summary: "整理记录详情(含逐文件结果)",
|
||||
params: IdParam,
|
||||
response: organization.PublicOrganizationRecord,
|
||||
}),
|
||||
changes: ep({
|
||||
method: "get",
|
||||
path: "/organization-records/changes",
|
||||
scope: "organize.read",
|
||||
summary: "按 changeSeq 增量读取(含既有记录状态更新)",
|
||||
query: organization.OrganizationChangesQuery,
|
||||
response: organization.OrganizationChangesResponse,
|
||||
}),
|
||||
create: ep({
|
||||
method: "post",
|
||||
path: "/organization-records",
|
||||
scope: "organize.execute",
|
||||
summary: "手动发起整理",
|
||||
body: organization.OrganizeRequest,
|
||||
response: organization.OrganizeAccepted,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 能力 ──
|
||||
capabilities: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/capabilities",
|
||||
scope: "read",
|
||||
summary: "能力注册中心描述",
|
||||
response: z.object({ items: z.array(CapabilityDescriptor) }),
|
||||
}),
|
||||
invoke: ep({
|
||||
method: "post",
|
||||
path: "/capabilities/{capabilityId}/invoke",
|
||||
scope: "read",
|
||||
summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)",
|
||||
params: z.object({ capabilityId: KnownCapabilityId }),
|
||||
body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }),
|
||||
response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }),
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 设置 ──
|
||||
settings: {
|
||||
get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }),
|
||||
update: ep({
|
||||
method: "patch",
|
||||
path: "/settings",
|
||||
scope: "admin",
|
||||
summary: "修改全局设置",
|
||||
body: GlobalSettingsUpdate,
|
||||
response: GlobalSettings,
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
|
||||
export function buildPath(path: string, params: Record<string, string> = {}): string {
|
||||
const filled = path.replace(/\{(\w+)\}/g, (_, k: string) => {
|
||||
const v = params[k];
|
||||
if (v === undefined) throw new Error(`缺少路径参数 ${k}`);
|
||||
return encodeURIComponent(v);
|
||||
});
|
||||
return `${API_PREFIX}${filled}`;
|
||||
}
|
||||
|
||||
/** 扁平列出全部端点(生成 OpenAPI、权限表与测试用) */
|
||||
export function listEndpoints(): EndpointDef[] {
|
||||
return Object.values(Api).flatMap((group) => Object.values(group) as EndpointDef[]);
|
||||
}
|
||||
|
||||
export type EndpointBody<E extends EndpointDef> = E["body"] extends z.ZodType ? z.input<E["body"]> : never;
|
||||
export type EndpointResponse<E extends EndpointDef> = z.infer<E["response"]>;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
|
||||
import { Segment } from "./content";
|
||||
|
||||
// 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2)
|
||||
// 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。
|
||||
|
||||
export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]);
|
||||
export type AssetKind = z.infer<typeof AssetKind>;
|
||||
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
|
||||
export type AssetStatus = z.infer<typeof AssetStatus>;
|
||||
|
||||
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */
|
||||
export const DeliveryTraits = z.object({
|
||||
resolution: z.string().optional(),
|
||||
videoCodec: z.string().optional(),
|
||||
audioCodecs: z.array(z.string()).optional(),
|
||||
hdr: z.array(z.string()).optional(),
|
||||
sourceType: z.string().optional(),
|
||||
releaseGroup: z.string().optional(),
|
||||
subtitles: z.array(z.string()).optional(),
|
||||
});
|
||||
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
|
||||
|
||||
export const MediaAsset = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
kind: AssetKind,
|
||||
/** 附属文件(字幕/音轨)指向主视频资产 */
|
||||
parentAssetId: Id.nullable(),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
contentHash: z.string().nullable(),
|
||||
hashAlgorithm: z.string().nullable(),
|
||||
sampleDigest: z.string().nullable(),
|
||||
/** ffprobe/MediaInfo 结果 */
|
||||
mediaInfo: JsonObject,
|
||||
delivery: DeliveryTraits,
|
||||
/** 内容版本:院线版/导演剪辑版等 */
|
||||
contentEdition: z.string().nullable(),
|
||||
sourceReleaseId: Id.nullable(),
|
||||
downloadTaskId: Id.nullable(),
|
||||
status: AssetStatus,
|
||||
verifiedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type MediaAsset = z.infer<typeof MediaAsset>;
|
||||
export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({
|
||||
mediaEntityId: true,
|
||||
parentAssetId: true,
|
||||
sizeBytes: true,
|
||||
contentHash: true,
|
||||
hashAlgorithm: true,
|
||||
sampleDigest: true,
|
||||
mediaInfo: true,
|
||||
delivery: true,
|
||||
contentEdition: true,
|
||||
sourceReleaseId: true,
|
||||
downloadTaskId: true,
|
||||
status: true,
|
||||
verifiedAt: true,
|
||||
});
|
||||
export type MediaAssetCreate = z.infer<typeof MediaAssetCreate>;
|
||||
export const MediaAssetUpdate = MediaAssetCreate.partial();
|
||||
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
|
||||
|
||||
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
|
||||
export type CoverageKind = z.infer<typeof CoverageKind>;
|
||||
|
||||
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
|
||||
export const AssetUnitCoverage = EntityBase.extend({
|
||||
mediaAssetId: Id,
|
||||
contentUnitId: Id,
|
||||
coverage: CoverageKind,
|
||||
segment: Segment.nullable(),
|
||||
evidence: JsonObject,
|
||||
});
|
||||
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
|
||||
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
|
||||
coverage: true,
|
||||
segment: true,
|
||||
evidence: true,
|
||||
});
|
||||
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
|
||||
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
|
||||
mediaAssetId: true,
|
||||
contentUnitId: true,
|
||||
}).partial();
|
||||
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
|
||||
|
||||
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
|
||||
export type LocationKind = z.infer<typeof LocationKind>;
|
||||
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
|
||||
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
|
||||
export type LocationStatus = z.infer<typeof LocationStatus>;
|
||||
|
||||
export const AssetLocation = EntityBase.extend({
|
||||
mediaAssetId: Id,
|
||||
locationKind: LocationKind,
|
||||
libraryRootId: Id.nullable(),
|
||||
downloaderInstanceId: Id.nullable(),
|
||||
/** 相对库根(或下载器保存根)的路径 */
|
||||
relativePath: z.string().min(1),
|
||||
status: LocationStatus,
|
||||
isPrimary: z.boolean(),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
inode: z.number().int().nullable(),
|
||||
device: z.number().int().nullable(),
|
||||
mtime: Timestamp.nullable(),
|
||||
sidecarOfLocationId: Id.nullable(),
|
||||
lastSeenAt: Timestamp.nullable(),
|
||||
lastCheckedAt: Timestamp.nullable(),
|
||||
checkDetail: JsonObject,
|
||||
});
|
||||
export type AssetLocation = z.infer<typeof AssetLocation>;
|
||||
export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED)
|
||||
.partial({
|
||||
locationKind: true,
|
||||
libraryRootId: true,
|
||||
downloaderInstanceId: true,
|
||||
status: true,
|
||||
isPrimary: true,
|
||||
sizeBytes: true,
|
||||
inode: true,
|
||||
device: true,
|
||||
mtime: true,
|
||||
sidecarOfLocationId: true,
|
||||
lastSeenAt: true,
|
||||
lastCheckedAt: true,
|
||||
checkDetail: true,
|
||||
})
|
||||
.refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, {
|
||||
message: "library 位置必须指定 libraryRootId",
|
||||
});
|
||||
export type AssetLocationCreate = z.infer<typeof AssetLocationCreate>;
|
||||
export const AssetLocationUpdate = AssetLocation.pick({
|
||||
status: true,
|
||||
isPrimary: true,
|
||||
sizeBytes: true,
|
||||
inode: true,
|
||||
device: true,
|
||||
mtime: true,
|
||||
lastSeenAt: true,
|
||||
lastCheckedAt: true,
|
||||
checkDetail: true,
|
||||
}).partial();
|
||||
export type AssetLocationUpdate = z.infer<typeof AssetLocationUpdate>;
|
||||
|
||||
export const BindingKind = z.enum(["entity", "season"]);
|
||||
export type BindingKind = z.infer<typeof BindingKind>;
|
||||
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
|
||||
export type BindingStatus = z.infer<typeof BindingStatus>;
|
||||
|
||||
export const PathBinding = EntityBase.extend({
|
||||
mediaEntityId: Id,
|
||||
contentUnitId: Id.nullable(),
|
||||
seasonNumber: z.number().int().nonnegative().nullable(),
|
||||
bindingKind: BindingKind,
|
||||
libraryRootId: Id,
|
||||
relativePath: z.string().min(1),
|
||||
templateVersion: z.string().nullable(),
|
||||
status: BindingStatus,
|
||||
boundAt: Timestamp,
|
||||
boundBy: z.string().nullable(),
|
||||
supersededById: Id.nullable(),
|
||||
note: z.string().nullable(),
|
||||
});
|
||||
export type PathBinding = z.infer<typeof PathBinding>;
|
||||
export const PathBindingCreate = PathBinding.pick({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
seasonNumber: true,
|
||||
bindingKind: true,
|
||||
libraryRootId: true,
|
||||
relativePath: true,
|
||||
templateVersion: true,
|
||||
status: true,
|
||||
note: true,
|
||||
})
|
||||
.partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true })
|
||||
.refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), {
|
||||
message: "season 绑定必须给 seasonNumber,entity 绑定不能给",
|
||||
});
|
||||
export type PathBindingCreate = z.infer<typeof PathBindingCreate>;
|
||||
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
|
||||
export const PathBindingUpdate = z.object({
|
||||
status: BindingStatus.optional(),
|
||||
supersededById: Id.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
});
|
||||
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { z } from "zod";
|
||||
import { MediaAsset } from "./assets";
|
||||
import type { KnownCapabilityId } from "./capability";
|
||||
import { Id, JsonObject, NonNegInt } from "./common";
|
||||
import { MediaEntity, MediaKind } from "./content";
|
||||
import { AcquisitionPlan, DownloadTask, Task, TaskStatus } from "./execution";
|
||||
import { ExternalEntityType, ExternalIdentity } from "./identity";
|
||||
import { ServerMapping } from "./ops";
|
||||
import { OrganizationAction, OrganizationRecord } from "./organization";
|
||||
import { AcquisitionSlot, PolicyRevision } from "./policy";
|
||||
import { SourceRelease } from "./sources";
|
||||
|
||||
// 首批能力的输入/输出 schema;名字与 CAPABILITY_CATALOG 中的 inputSchema/outputSchema 一致。
|
||||
// 写能力的输入只接受计划/对象 ID,不接受模型自拟的幂等键或任意路径。
|
||||
|
||||
const Limit = z.number().int().min(1).max(100).default(20);
|
||||
|
||||
/** 写能力的通用受理结果:返回 task/plan ID,由任务页追踪(REQ §12.3) */
|
||||
export const Accepted = z.object({ taskId: Id, planId: Id.nullable() });
|
||||
export type Accepted = z.infer<typeof Accepted>;
|
||||
|
||||
export const CatalogLookupInput = z
|
||||
.object({
|
||||
query: z.string().min(1).max(200).optional(),
|
||||
externalId: z.object({ provider: z.string(), entityType: ExternalEntityType, externalId: z.string() }).optional(),
|
||||
kind: MediaKind.optional(),
|
||||
year: z.number().int().optional(),
|
||||
includeRemote: z.boolean().default(false),
|
||||
limit: Limit,
|
||||
})
|
||||
.refine((v) => !!v.query || !!v.externalId, { message: "query 与 externalId 至少给一个" });
|
||||
export const CatalogLookupOutput = z.object({
|
||||
local: z.array(z.object({ entity: MediaEntity, score: z.number(), matchedAlias: z.string().nullable() })),
|
||||
remote: z.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
entityType: ExternalEntityType,
|
||||
externalId: z.string(),
|
||||
title: z.string(),
|
||||
year: z.number().int().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ReleaseSearchInput = z.object({
|
||||
query: z.string().min(1).max(200).optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
siteIds: z.array(Id).optional(),
|
||||
/** 本地快照不足时是否实时补查 */
|
||||
allowLiveSearch: z.boolean().default(true),
|
||||
limit: Limit,
|
||||
});
|
||||
export const ReleaseSearchOutput = z.object({
|
||||
items: z.array(SourceRelease),
|
||||
/** 各站点快照新鲜度与是否实时补查 */
|
||||
coverage: z.array(
|
||||
z.object({ siteId: Id, fromSnapshot: z.boolean(), live: z.boolean(), error: z.string().nullable() }),
|
||||
),
|
||||
});
|
||||
|
||||
export const ReleaseInspectInput = z.object({ sourceReleaseId: Id, refreshDetail: z.boolean().default(false) });
|
||||
export const ReleaseInspectOutput = z.object({ release: SourceRelease, inventory: z.array(MediaAsset) });
|
||||
|
||||
export const IdentityResolveInput = z
|
||||
.object({
|
||||
observationId: Id.optional(),
|
||||
sourceReleaseId: Id.optional(),
|
||||
candidate: z
|
||||
.object({
|
||||
mediaEntityId: Id.optional(),
|
||||
provider: z.string().optional(),
|
||||
entityType: ExternalEntityType.optional(),
|
||||
externalId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
evidenceIds: z.array(Id).default([]),
|
||||
})
|
||||
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" });
|
||||
export const IdentityResolveOutput = z.object({
|
||||
status: z.enum(["resolved", "proposed", "ambiguous", "unidentifiable"]),
|
||||
mediaEntityId: Id.nullable(),
|
||||
identities: z.array(ExternalIdentity),
|
||||
decisionId: Id.nullable(),
|
||||
});
|
||||
|
||||
export const DownloadPlanInput = z.object({ slotId: Id, sourceReleaseId: Id });
|
||||
export const DownloadPlanOutput = z.object({
|
||||
plan: AcquisitionPlan.nullable(),
|
||||
slot: AcquisitionSlot,
|
||||
/** 硬条件不满足或预算/阈值阻止时的原因 */
|
||||
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
|
||||
requiresApproval: z.boolean(),
|
||||
});
|
||||
|
||||
export const DownloadSubmitInput = z.object({ planId: Id, approvalId: Id.optional() });
|
||||
export const DownloadSubmitOutput = Accepted.extend({ downloadTask: DownloadTask.nullable() });
|
||||
|
||||
export const OrganizePlanInput = z
|
||||
.object({
|
||||
downloadTaskId: Id.optional(),
|
||||
mediaAssetIds: z.array(Id).optional(),
|
||||
action: OrganizationAction.default("copy"),
|
||||
targetLibraryRootId: Id.optional(),
|
||||
})
|
||||
.refine((v) => !!v.downloadTaskId || !!v.mediaAssetIds?.length, { message: "需要 downloadTaskId 或 mediaAssetIds" });
|
||||
export const OrganizePlanOutput = z.object({
|
||||
planId: Id,
|
||||
payloadHash: z.string(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
sourcePath: z.string(),
|
||||
targetPath: z.string().nullable(),
|
||||
action: OrganizationAction,
|
||||
skipReason: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
conflicts: z.array(z.object({ code: z.string(), message: z.string(), path: z.string().nullable() })),
|
||||
requiresApproval: z.boolean(),
|
||||
});
|
||||
|
||||
export const OrganizeExecuteInput = z.object({ planId: Id, payloadHash: z.string().min(1), approvalId: Id.optional() });
|
||||
export const OrganizeExecuteOutput = Accepted.extend({ record: OrganizationRecord.nullable() });
|
||||
|
||||
export const TaskInspectInput = z.object({ taskId: Id });
|
||||
export const TaskInspectOutput = z.object({
|
||||
task: Task,
|
||||
children: z.array(Task),
|
||||
timeline: z.array(z.object({ at: z.string(), type: z.string(), summary: z.string(), data: JsonObject.optional() })),
|
||||
});
|
||||
|
||||
export const TaskRetryInput = z.object({ taskId: Id, reason: z.string().max(500).optional() });
|
||||
export const TaskRetryOutput = z.object({ taskId: Id, status: TaskStatus });
|
||||
|
||||
export const TaskCancelInput = z.object({ taskId: Id, reason: z.string().min(1).max(500) });
|
||||
export const TaskCancelOutput = z.object({ taskId: Id, status: TaskStatus, cancelRequested: z.boolean() });
|
||||
|
||||
export const PolicyPreviewInput = z.object({
|
||||
policyRevisionId: Id,
|
||||
sampleLimit: z.number().int().min(1).max(1000).default(200),
|
||||
});
|
||||
export const PolicyPreviewOutput = z.object({
|
||||
newlyMatched: NonNegInt,
|
||||
newlyExcluded: NonNegInt,
|
||||
samples: z.array(z.object({ sourceReleaseId: Id, before: z.string(), after: z.string(), reason: z.string() })),
|
||||
});
|
||||
|
||||
export const PolicyApplyInput = z.object({
|
||||
policyRevisionId: Id,
|
||||
expectedRevision: z.number().int().min(1),
|
||||
approvalId: Id.optional(),
|
||||
});
|
||||
export const PolicyApplyOutput = z.object({ policy: PolicyRevision });
|
||||
|
||||
export const InventoryQueryInput = z.object({
|
||||
mediaEntityId: Id.optional(),
|
||||
query: z.string().max(200).optional(),
|
||||
includeServerInventory: z.boolean().default(false),
|
||||
limit: Limit,
|
||||
});
|
||||
export const InventoryQueryOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
entity: MediaEntity,
|
||||
assets: z.array(MediaAsset),
|
||||
slots: z.array(AcquisitionSlot),
|
||||
identities: z.array(ExternalIdentity.pick({ provider: true, entityType: true, externalId: true, status: true })),
|
||||
/** 外部库存证据;"未知 ≠ 缺失" */
|
||||
server: z.array(ServerMapping),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
|
||||
export const CAPABILITY_IO = {
|
||||
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
|
||||
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
|
||||
"release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput },
|
||||
"identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput },
|
||||
"download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput },
|
||||
"download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput },
|
||||
"organize.plan": { input: OrganizePlanInput, output: OrganizePlanOutput },
|
||||
"organize.execute": { input: OrganizeExecuteInput, output: OrganizeExecuteOutput },
|
||||
"task.inspect": { input: TaskInspectInput, output: TaskInspectOutput },
|
||||
"task.retry": { input: TaskRetryInput, output: TaskRetryOutput },
|
||||
"task.cancel": { input: TaskCancelInput, output: TaskCancelOutput },
|
||||
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
|
||||
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
|
||||
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
|
||||
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
|
||||
|
||||
export type CatalogLookupInput = z.infer<typeof CatalogLookupInput>;
|
||||
export type CatalogLookupOutput = z.infer<typeof CatalogLookupOutput>;
|
||||
export type ReleaseSearchInput = z.infer<typeof ReleaseSearchInput>;
|
||||
export type ReleaseSearchOutput = z.infer<typeof ReleaseSearchOutput>;
|
||||
export type ReleaseInspectInput = z.infer<typeof ReleaseInspectInput>;
|
||||
export type ReleaseInspectOutput = z.infer<typeof ReleaseInspectOutput>;
|
||||
export type IdentityResolveInput = z.infer<typeof IdentityResolveInput>;
|
||||
export type IdentityResolveOutput = z.infer<typeof IdentityResolveOutput>;
|
||||
export type DownloadPlanInput = z.infer<typeof DownloadPlanInput>;
|
||||
export type DownloadPlanOutput = z.infer<typeof DownloadPlanOutput>;
|
||||
export type DownloadSubmitInput = z.infer<typeof DownloadSubmitInput>;
|
||||
export type DownloadSubmitOutput = z.infer<typeof DownloadSubmitOutput>;
|
||||
export type OrganizePlanInput = z.infer<typeof OrganizePlanInput>;
|
||||
export type OrganizePlanOutput = z.infer<typeof OrganizePlanOutput>;
|
||||
export type OrganizeExecuteInput = z.infer<typeof OrganizeExecuteInput>;
|
||||
export type OrganizeExecuteOutput = z.infer<typeof OrganizeExecuteOutput>;
|
||||
export type TaskInspectInput = z.infer<typeof TaskInspectInput>;
|
||||
export type TaskInspectOutput = z.infer<typeof TaskInspectOutput>;
|
||||
export type TaskRetryInput = z.infer<typeof TaskRetryInput>;
|
||||
export type TaskRetryOutput = z.infer<typeof TaskRetryOutput>;
|
||||
export type TaskCancelInput = z.infer<typeof TaskCancelInput>;
|
||||
export type TaskCancelOutput = z.infer<typeof TaskCancelOutput>;
|
||||
export type PolicyPreviewInput = z.infer<typeof PolicyPreviewInput>;
|
||||
export type PolicyPreviewOutput = z.infer<typeof PolicyPreviewOutput>;
|
||||
export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
|
||||
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
|
||||
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
|
||||
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ApiScope } from "./common";
|
||||
|
||||
/**
|
||||
* 能力调用上下文(REQ §10.3)。产品自有接口,不是 Pi SDK 原生 API。
|
||||
@@ -43,3 +44,237 @@ export interface CapabilityDefinition<I extends z.ZodType = z.ZodType, O extends
|
||||
idempotent: boolean;
|
||||
supportsPreview: boolean;
|
||||
}
|
||||
|
||||
// ── 能力描述符与首批能力(任务书 §3.1-9;REQ §10.3) ──
|
||||
|
||||
/** 风险级别:low 只读;medium 预览/可撤回写入;high 触发下载、文件变更或策略发布 */
|
||||
export const RiskLevel = z.enum(["low", "medium", "high"]);
|
||||
export type RiskLevel = z.infer<typeof RiskLevel>;
|
||||
|
||||
/** 接口类别(REQ §12.3):read / preview / propose / approve / execute */
|
||||
export const CapabilityKind = z.enum(["read", "preview", "propose", "approve", "execute"]);
|
||||
export type CapabilityKind = z.infer<typeof CapabilityKind>;
|
||||
|
||||
/** 首批能力 ID */
|
||||
export const CAPABILITY_IDS = [
|
||||
"catalog.lookup",
|
||||
"release.search",
|
||||
"release.inspect",
|
||||
"identity.resolve",
|
||||
"download.plan",
|
||||
"download.submit",
|
||||
"organize.plan",
|
||||
"organize.execute",
|
||||
"task.inspect",
|
||||
"task.retry",
|
||||
"task.cancel",
|
||||
"policy.preview",
|
||||
"policy.apply",
|
||||
"inventory.query",
|
||||
] as const;
|
||||
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
|
||||
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
|
||||
|
||||
/** schema 引用:指向 contracts 中导出的 schema 名(如 "ReleaseSearchInput"),便于序列化给 UI/MCP 列表 */
|
||||
export const SchemaRef = z.string().regex(/^[A-Z][A-Za-z0-9]*$/);
|
||||
|
||||
/** 能力注册中心的可序列化描述(处理器在 server 侧挂载;模型只能看到白名单内的描述) */
|
||||
export const CapabilityDescriptor = z.object({
|
||||
id: CapabilityId,
|
||||
version: z.number().int().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
kind: CapabilityKind,
|
||||
inputSchema: SchemaRef,
|
||||
outputSchema: SchemaRef,
|
||||
/** 调用所需 API scope */
|
||||
scope: ApiScope,
|
||||
riskLevel: RiskLevel,
|
||||
idempotent: z.boolean(),
|
||||
supportsPreview: z.boolean(),
|
||||
});
|
||||
export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
|
||||
|
||||
/** 首批能力的描述(inputSchema/outputSchema 名对应 capabilities-io.ts 中的导出) */
|
||||
export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor> = {
|
||||
"catalog.lookup": d(
|
||||
"catalog.lookup",
|
||||
"查作品",
|
||||
"按标题/别名/外部 ID 查本地作品与元数据候选",
|
||||
"read",
|
||||
"CatalogLookup",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"release.search": d(
|
||||
"release.search",
|
||||
"搜资源",
|
||||
"先查本地资源索引与站点快照,不足时对已开启站点实时补查",
|
||||
"read",
|
||||
"ReleaseSearch",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"release.inspect": d(
|
||||
"release.inspect",
|
||||
"看资源详情",
|
||||
"读取站点资源详情、文件清单、促销与解析结果",
|
||||
"read",
|
||||
"ReleaseInspect",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"identity.resolve": d(
|
||||
"identity.resolve",
|
||||
"识别身份",
|
||||
"为观察/资源提出身份映射;高证据自动接受,歧义进入待处理",
|
||||
"propose",
|
||||
"IdentityResolve",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
"download.plan": d(
|
||||
"download.plan",
|
||||
"生成下载计划",
|
||||
"评估候选并生成带 Slot/预算预留的下载计划(不提交)",
|
||||
"preview",
|
||||
"DownloadPlan",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
"download.submit": d(
|
||||
"download.submit",
|
||||
"提交下载",
|
||||
"按已保存的计划提交到下载器;校验授权与硬阈值",
|
||||
"execute",
|
||||
"DownloadSubmit",
|
||||
"organize.execute",
|
||||
"high",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"organize.plan": d(
|
||||
"organize.plan",
|
||||
"生成整理计划",
|
||||
"冻结源/目标路径、操作类型与前置条件",
|
||||
"preview",
|
||||
"OrganizePlan",
|
||||
"organize.read",
|
||||
"low",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
"organize.execute": d(
|
||||
"organize.execute",
|
||||
"执行整理",
|
||||
"执行已保存的整理计划(Copy/Move),生成整理记录",
|
||||
"execute",
|
||||
"OrganizeExecute",
|
||||
"organize.execute",
|
||||
"high",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"task.inspect": d(
|
||||
"task.inspect",
|
||||
"查看任务",
|
||||
"读取任务状态、业务时间线与错误",
|
||||
"read",
|
||||
"TaskInspect",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"task.retry": d(
|
||||
"task.retry",
|
||||
"重试任务",
|
||||
"对失败/死信任务重新排队;副作用经操作账本去重",
|
||||
"execute",
|
||||
"TaskRetry",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"task.cancel": d(
|
||||
"task.cancel",
|
||||
"取消任务",
|
||||
"停止未来步骤,不撤销已完成的外部动作",
|
||||
"execute",
|
||||
"TaskCancel",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"policy.preview": d(
|
||||
"policy.preview",
|
||||
"预览策略",
|
||||
"用历史数据回放策略草案,展示新增命中与误排除",
|
||||
"preview",
|
||||
"PolicyPreview",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
"policy.apply": d(
|
||||
"policy.apply",
|
||||
"发布策略",
|
||||
"发布策略版本(需授权);不直接执行 JavaScript",
|
||||
"execute",
|
||||
"PolicyApply",
|
||||
"admin",
|
||||
"high",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
"inventory.query": d(
|
||||
"inventory.query",
|
||||
"查库存",
|
||||
"查询本地资产、Slot 满足度与外部库存证据",
|
||||
"read",
|
||||
"InventoryQuery",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
function d(
|
||||
id: KnownCapabilityId,
|
||||
title: string,
|
||||
description: string,
|
||||
kind: CapabilityKind,
|
||||
schemaBase: string,
|
||||
scope: ApiScope,
|
||||
riskLevel: RiskLevel,
|
||||
idempotent: boolean,
|
||||
supportsPreview: boolean,
|
||||
): CapabilityDescriptor {
|
||||
return {
|
||||
id,
|
||||
version: 1,
|
||||
title,
|
||||
description,
|
||||
kind,
|
||||
inputSchema: `${schemaBase}Input`,
|
||||
outputSchema: `${schemaBase}Output`,
|
||||
scope,
|
||||
riskLevel,
|
||||
idempotent,
|
||||
supportsPreview,
|
||||
};
|
||||
}
|
||||
@@ -91,3 +91,32 @@ export const HealthStatus = z.object({
|
||||
time: Timestamp,
|
||||
});
|
||||
export type HealthStatus = z.infer<typeof HealthStatus>;
|
||||
|
||||
/** 日期(无时区),如 air_date:YYYY-MM-DD */
|
||||
export const DateOnly = z.iso.date();
|
||||
export type DateOnly = z.infer<typeof DateOnly>;
|
||||
|
||||
/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */
|
||||
export const JsonObject = z.record(z.string(), z.unknown());
|
||||
export type JsonObject = z.infer<typeof JsonObject>;
|
||||
|
||||
/** 字节数、计数等非负整数(DB 为 bigint,客户端按 number 解析,上限 2^53) */
|
||||
export const NonNegInt = z.number().int().nonnegative();
|
||||
|
||||
/** 操作者类型:与 DB 中 actor_type 列一致 */
|
||||
export const ActorType = z.enum(["user", "agent", "system", "api_key"]);
|
||||
export type ActorType = z.infer<typeof ActorType>;
|
||||
|
||||
/** 实体行的公共字段 */
|
||||
export const EntityBase = z.object({
|
||||
id: Id,
|
||||
createdAt: Timestamp,
|
||||
updatedAt: Timestamp,
|
||||
});
|
||||
|
||||
/** 所有实体 Create 输入都要剔除的服务端字段 */
|
||||
export const SERVER_MANAGED = { id: true, createdAt: true, updatedAt: true } as const;
|
||||
|
||||
/** 路径参数 `{id}` */
|
||||
export const IdParam = z.object({ id: Id });
|
||||
export type IdParam = z.infer<typeof IdParam>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
import { DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
|
||||
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
|
||||
|
||||
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
|
||||
export type MediaKind = z.infer<typeof MediaKind>;
|
||||
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
|
||||
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
|
||||
|
||||
export const MediaEntity = EntityBase.extend({
|
||||
kind: MediaKind,
|
||||
title: z.string().min(1),
|
||||
originalTitle: z.string().nullable(),
|
||||
year: z.number().int().min(1870).max(2200).nullable(),
|
||||
status: MediaEntityStatus,
|
||||
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
|
||||
mergedIntoId: Id.nullable(),
|
||||
/** 最终展示字段(每个字段带来源/语言/时间) */
|
||||
metadata: JsonObject,
|
||||
/** 人工锁定的字段名;刷新不得覆盖 */
|
||||
fieldLocks: z.array(z.string()),
|
||||
lastMetadataRefreshAt: Timestamp.nullable(),
|
||||
});
|
||||
export type MediaEntity = z.infer<typeof MediaEntity>;
|
||||
export const MediaEntityCreate = MediaEntity.pick({
|
||||
kind: true,
|
||||
title: true,
|
||||
originalTitle: true,
|
||||
year: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
fieldLocks: true,
|
||||
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
|
||||
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
|
||||
/** 合并通过专门动作完成,Update 不接受 status=merged */
|
||||
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
|
||||
status: MediaEntityStatus.exclude(["merged"]).optional(),
|
||||
});
|
||||
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
|
||||
|
||||
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
|
||||
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
|
||||
/** 综艺内容类型(REQ §5.1) */
|
||||
export const ContentType = z.enum([
|
||||
"main",
|
||||
"extra",
|
||||
"behind_the_scenes",
|
||||
"performance_only",
|
||||
"preview",
|
||||
"reaction",
|
||||
"unknown",
|
||||
]);
|
||||
export type ContentType = z.infer<typeof ContentType>;
|
||||
export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
|
||||
export type Segment = z.infer<typeof Segment>;
|
||||
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
|
||||
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
|
||||
|
||||
export const ContentUnit = EntityBase.extend({
|
||||
mediaEntityId: Id,
|
||||
parentUnitId: Id.nullable(),
|
||||
kind: ContentUnitKind,
|
||||
contentType: ContentType,
|
||||
segment: Segment,
|
||||
seasonNumber: z.number().int().nonnegative().nullable(),
|
||||
episodeNumber: z.number().int().nonnegative().nullable(),
|
||||
/** 来源期号原文,如 "第十期"、"20260927" */
|
||||
sourceIssue: z.string().nullable(),
|
||||
airDate: DateOnly.nullable(),
|
||||
displayOrder: z.number().nullable(),
|
||||
title: z.string().nullable(),
|
||||
status: ContentUnitStatus,
|
||||
/** 人工确认的编号锁定;刷新不得改编号 */
|
||||
numberingLocked: z.boolean(),
|
||||
metadata: JsonObject,
|
||||
fieldLocks: z.array(z.string()),
|
||||
});
|
||||
export type ContentUnit = z.infer<typeof ContentUnit>;
|
||||
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
|
||||
parentUnitId: true,
|
||||
contentType: true,
|
||||
segment: true,
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
sourceIssue: true,
|
||||
airDate: true,
|
||||
displayOrder: true,
|
||||
title: true,
|
||||
status: true,
|
||||
numberingLocked: true,
|
||||
metadata: true,
|
||||
fieldLocks: true,
|
||||
});
|
||||
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
|
||||
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
|
||||
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
|
||||
|
||||
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
|
||||
export type AliasKind = z.infer<typeof AliasKind>;
|
||||
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
|
||||
export type AliasSource = z.infer<typeof AliasSource>;
|
||||
|
||||
export const TitleAlias = EntityBase.extend({
|
||||
mediaEntityId: Id,
|
||||
contentUnitId: Id.nullable(),
|
||||
alias: z.string().min(1),
|
||||
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
|
||||
normalized: z.string().min(1),
|
||||
language: z.string().nullable(),
|
||||
kind: AliasKind,
|
||||
source: AliasSource,
|
||||
locked: z.boolean(),
|
||||
});
|
||||
export type TitleAlias = z.infer<typeof TitleAlias>;
|
||||
/** normalized 由服务端计算,不接受客户端传入 */
|
||||
export const TitleAliasCreate = TitleAlias.pick({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
alias: true,
|
||||
language: true,
|
||||
kind: true,
|
||||
source: true,
|
||||
locked: true,
|
||||
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
|
||||
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
|
||||
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
|
||||
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { Timestamp } from "./common";
|
||||
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent";
|
||||
import { Id, NonNegInt, Timestamp } from "./common";
|
||||
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution";
|
||||
import { OrganizationStatus } from "./organization";
|
||||
import { FeedHealth } from "./sources";
|
||||
|
||||
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
|
||||
export const EventEnvelope = z.object({
|
||||
@@ -15,10 +19,30 @@ export type EventEnvelope<P = unknown> = {
|
||||
payload: P;
|
||||
};
|
||||
|
||||
/** 已知事件类型;业务事件后续按 `域.动作` 追加 */
|
||||
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
|
||||
export const EventType = {
|
||||
heartbeat: "system.heartbeat",
|
||||
resync: "system.resync",
|
||||
|
||||
taskCreated: "task.created",
|
||||
taskStatusChanged: "task.status_changed",
|
||||
|
||||
downloadSubmitted: "download.submitted",
|
||||
downloadProgress: "download.progress",
|
||||
downloadStatusChanged: "download.status_changed",
|
||||
downloadThreshold: "download.threshold",
|
||||
|
||||
organizationChanged: "organization.changed",
|
||||
|
||||
agentRunStatus: "agent.run_status",
|
||||
agentTextDelta: "agent.text_delta",
|
||||
agentToolCall: "agent.tool_call",
|
||||
|
||||
feedFetched: "feed.fetched",
|
||||
feedHealthChanged: "feed.health_changed",
|
||||
|
||||
decisionCreated: "decision.created",
|
||||
decisionUpdated: "decision.updated",
|
||||
} as const;
|
||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
|
||||
@@ -26,3 +50,143 @@ export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
export const HeartbeatPayload = z.object({
|
||||
role: z.string(),
|
||||
});
|
||||
|
||||
/** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */
|
||||
export const ResyncPayload = z.object({ reason: z.string() });
|
||||
|
||||
export const TaskCreatedPayload = z.object({
|
||||
taskId: Id,
|
||||
kind: TaskKind,
|
||||
subjectType: z.string().nullable(),
|
||||
subjectId: Id.nullable(),
|
||||
});
|
||||
export const TaskStatusChangedPayload = z.object({
|
||||
taskId: Id,
|
||||
kind: TaskKind,
|
||||
from: TaskStatus.nullable(),
|
||||
to: TaskStatus,
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const DownloadSubmittedPayload = z.object({
|
||||
downloadTaskId: Id,
|
||||
instanceId: Id,
|
||||
torrentHash: z.string(),
|
||||
planId: Id.nullable(),
|
||||
name: z.string(),
|
||||
});
|
||||
export const DownloadProgressPayload = z.object({
|
||||
downloadTaskId: Id,
|
||||
progress: z.number().min(0).max(1),
|
||||
downloadedBytes: NonNegInt,
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
});
|
||||
export const DownloadStatusChangedPayload = z.object({
|
||||
downloadTaskId: Id,
|
||||
from: DownloadStatus.nullable(),
|
||||
to: DownloadStatus,
|
||||
pauseReason: PauseReason.nullable(),
|
||||
});
|
||||
/** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */
|
||||
export const DownloadThresholdPayload = z.object({
|
||||
volumeKey: z.string(),
|
||||
state: z.enum(["tripped", "recovered", "unknown"]),
|
||||
usedPercent: z.number().min(0).max(100).nullable(),
|
||||
pausedTaskIds: z.array(Id),
|
||||
});
|
||||
|
||||
/** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */
|
||||
export const OrganizationChangedPayload = z.object({
|
||||
recordId: Id,
|
||||
status: OrganizationStatus,
|
||||
changeSeq: z.string().regex(/^\d+$/),
|
||||
mediaEntityId: Id.nullable(),
|
||||
});
|
||||
|
||||
export const AgentRunStatusPayload = z.object({
|
||||
runId: Id,
|
||||
status: AgentRunStatus,
|
||||
summary: z.string().nullable(),
|
||||
});
|
||||
/** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */
|
||||
export const AgentTextDeltaPayload = z.object({
|
||||
runId: Id,
|
||||
runSeq: NonNegInt,
|
||||
delta: z.string(),
|
||||
});
|
||||
export const AgentToolCallPayload = z.object({
|
||||
runId: Id,
|
||||
runSeq: NonNegInt,
|
||||
capabilityId: z.string(),
|
||||
phase: z.enum(["started", "succeeded", "failed", "denied"]),
|
||||
/** 已脱敏的摘要,不含凭据/私有 URL */
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export const FeedFetchedPayload = z.object({
|
||||
feedId: Id,
|
||||
newItems: NonNegInt,
|
||||
updatedItems: NonNegInt,
|
||||
notModified: z.boolean(),
|
||||
});
|
||||
export const FeedHealthChangedPayload = z.object({
|
||||
feedId: Id,
|
||||
from: FeedHealth.nullable(),
|
||||
to: FeedHealth,
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const DecisionCreatedPayload = z.object({
|
||||
decisionId: Id,
|
||||
kind: DecisionKind,
|
||||
severity: Severity,
|
||||
title: z.string(),
|
||||
});
|
||||
export const DecisionUpdatedPayload = z.object({
|
||||
decisionId: Id,
|
||||
status: DecisionStatus,
|
||||
});
|
||||
|
||||
/** 事件类型 → payload schema */
|
||||
export const EVENT_PAYLOADS = {
|
||||
"system.heartbeat": HeartbeatPayload,
|
||||
"system.resync": ResyncPayload,
|
||||
"task.created": TaskCreatedPayload,
|
||||
"task.status_changed": TaskStatusChangedPayload,
|
||||
"download.submitted": DownloadSubmittedPayload,
|
||||
"download.progress": DownloadProgressPayload,
|
||||
"download.status_changed": DownloadStatusChangedPayload,
|
||||
"download.threshold": DownloadThresholdPayload,
|
||||
"organization.changed": OrganizationChangedPayload,
|
||||
"agent.run_status": AgentRunStatusPayload,
|
||||
"agent.text_delta": AgentTextDeltaPayload,
|
||||
"agent.tool_call": AgentToolCallPayload,
|
||||
"feed.fetched": FeedFetchedPayload,
|
||||
"feed.health_changed": FeedHealthChangedPayload,
|
||||
"decision.created": DecisionCreatedPayload,
|
||||
"decision.updated": DecisionUpdatedPayload,
|
||||
} as const satisfies Record<EventType, z.ZodType>;
|
||||
|
||||
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
|
||||
|
||||
/** 已知事件的判别联合:`{ type, payload }` */
|
||||
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[EventType];
|
||||
|
||||
/** 已知事件信封的 schema(判别联合) */
|
||||
export const TypedEventEnvelope = z.discriminatedUnion(
|
||||
"type",
|
||||
Object.entries(EVENT_PAYLOADS).map(([type, payload]) =>
|
||||
z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }),
|
||||
) as unknown as [z.ZodObject, ...z.ZodObject[]],
|
||||
);
|
||||
|
||||
export function isEventType(type: string): type is EventType {
|
||||
return Object.hasOwn(EVENT_PAYLOADS, type);
|
||||
}
|
||||
|
||||
/** 校验一个事件 payload;不合法返回 null */
|
||||
export function parseEventPayload<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
|
||||
const schema: z.ZodType = EVENT_PAYLOADS[type];
|
||||
const r = schema.safeParse(payload);
|
||||
return r.success ? (r.data as EventPayload<T>) : null;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
|
||||
import { AcquisitionPriority } from "./policy";
|
||||
|
||||
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
|
||||
|
||||
/** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */
|
||||
export const TaskKind = z.enum([
|
||||
"ingestFeed",
|
||||
"resolveObservation",
|
||||
"evaluateAcquisition",
|
||||
"submitDownload",
|
||||
"verifyAndImport",
|
||||
"refreshMetadata",
|
||||
"reconcileLibrary",
|
||||
"cleanupDownloadCache",
|
||||
"resolveDecision",
|
||||
"agentRun",
|
||||
]);
|
||||
export type TaskKind = z.infer<typeof TaskKind>;
|
||||
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
|
||||
export type TaskStatus = z.infer<typeof TaskStatus>;
|
||||
|
||||
export const Task = EntityBase.extend({
|
||||
kind: TaskKind,
|
||||
handlerVersion: z.number().int().min(1),
|
||||
idempotencyKey: z.string().min(1),
|
||||
status: TaskStatus,
|
||||
/** 数值越小越先执行 */
|
||||
priority: z.number().int(),
|
||||
input: JsonObject,
|
||||
output: JsonObject.nullable(),
|
||||
subjectType: z.string().nullable(),
|
||||
subjectId: Id.nullable(),
|
||||
parentTaskId: Id.nullable(),
|
||||
engineRunId: z.string().nullable(),
|
||||
attempts: NonNegInt,
|
||||
maxAttempts: z.number().int().min(1),
|
||||
nextRunAt: Timestamp,
|
||||
waitReason: z.string().nullable(),
|
||||
cancelRequestedAt: Timestamp.nullable(),
|
||||
cancelReason: z.string().nullable(),
|
||||
lastError: JsonObject.nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
triggeredBy: z.string().nullable(),
|
||||
});
|
||||
export type Task = z.infer<typeof Task>;
|
||||
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
|
||||
export const TaskCreate = Task.pick({
|
||||
kind: true,
|
||||
handlerVersion: true,
|
||||
idempotencyKey: true,
|
||||
priority: true,
|
||||
input: true,
|
||||
subjectType: true,
|
||||
subjectId: true,
|
||||
parentTaskId: true,
|
||||
maxAttempts: true,
|
||||
nextRunAt: true,
|
||||
triggeredBy: true,
|
||||
}).partial({
|
||||
handlerVersion: true,
|
||||
priority: true,
|
||||
input: true,
|
||||
subjectType: true,
|
||||
subjectId: true,
|
||||
parentTaskId: true,
|
||||
maxAttempts: true,
|
||||
nextRunAt: true,
|
||||
triggeredBy: true,
|
||||
});
|
||||
export type TaskCreate = z.infer<typeof TaskCreate>;
|
||||
export const TaskUpdate = Task.pick({
|
||||
status: true,
|
||||
output: true,
|
||||
engineRunId: true,
|
||||
attempts: true,
|
||||
nextRunAt: true,
|
||||
waitReason: true,
|
||||
lastError: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type TaskUpdate = z.infer<typeof TaskUpdate>;
|
||||
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
|
||||
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
|
||||
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
|
||||
|
||||
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
|
||||
export type PlanPurpose = z.infer<typeof PlanPurpose>;
|
||||
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
|
||||
export type PlanStatus = z.infer<typeof PlanStatus>;
|
||||
|
||||
/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */
|
||||
export const AcquisitionPlan = EntityBase.extend({
|
||||
slotId: Id,
|
||||
sourceReleaseId: Id,
|
||||
taskId: Id.nullable(),
|
||||
purpose: PlanPurpose,
|
||||
status: PlanStatus,
|
||||
priority: AcquisitionPriority,
|
||||
/** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */
|
||||
evaluation: JsonObject,
|
||||
reservedBytes: NonNegInt,
|
||||
budgetReservation: JsonObject,
|
||||
payloadHash: z.string().min(1),
|
||||
expectedRevision: z.number().int().min(1),
|
||||
idempotencyKey: z.string().min(1),
|
||||
expiresAt: Timestamp.nullable(),
|
||||
statusReason: z.string().nullable(),
|
||||
});
|
||||
export type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
|
||||
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
|
||||
taskId: true,
|
||||
purpose: true,
|
||||
priority: true,
|
||||
evaluation: true,
|
||||
reservedBytes: true,
|
||||
budgetReservation: true,
|
||||
expectedRevision: true,
|
||||
expiresAt: true,
|
||||
statusReason: true,
|
||||
});
|
||||
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
|
||||
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
|
||||
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
|
||||
|
||||
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
|
||||
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
|
||||
export const DownloadStatus = z.enum([
|
||||
"queued",
|
||||
"submitting",
|
||||
"downloading",
|
||||
"paused",
|
||||
"stalled",
|
||||
"completed",
|
||||
"importing",
|
||||
"imported",
|
||||
"failed",
|
||||
"removed",
|
||||
"missing",
|
||||
]);
|
||||
export type DownloadStatus = z.infer<typeof DownloadStatus>;
|
||||
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
|
||||
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
|
||||
export type PauseReason = z.infer<typeof PauseReason>;
|
||||
export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/);
|
||||
|
||||
export const DownloadTask = EntityBase.extend({
|
||||
instanceId: Id,
|
||||
/** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */
|
||||
torrentHash: TorrentHash,
|
||||
infohashV1: z.string().nullable(),
|
||||
infohashV2: z.string().nullable(),
|
||||
acquisitionPlanId: Id.nullable(),
|
||||
sourceReleaseId: Id.nullable(),
|
||||
name: z.string(),
|
||||
savePath: z.string().nullable(),
|
||||
contentPath: z.string().nullable(),
|
||||
category: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
/** 是否在管理标签范围内;标签移除后暂停后续自动动作 */
|
||||
managed: z.boolean(),
|
||||
origin: DownloadOrigin,
|
||||
status: DownloadStatus,
|
||||
pauseReason: PauseReason.nullable(),
|
||||
rawState: z.string().nullable(),
|
||||
progress: z.number().min(0).max(1),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
downloadedBytes: NonNegInt,
|
||||
remainingBytes: NonNegInt.nullable(),
|
||||
ratio: z.number().nullable(),
|
||||
seedingTimeSec: NonNegInt.nullable(),
|
||||
completedAt: Timestamp.nullable(),
|
||||
lastProgressAt: Timestamp.nullable(),
|
||||
/** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */
|
||||
noProgressSince: Timestamp.nullable(),
|
||||
lastSyncedAt: Timestamp.nullable(),
|
||||
nextCheckAt: Timestamp.nullable(),
|
||||
removedAt: Timestamp.nullable(),
|
||||
fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(),
|
||||
lastError: JsonObject.nullable(),
|
||||
});
|
||||
export type DownloadTask = z.infer<typeof DownloadTask>;
|
||||
export const DownloadTaskCreate = DownloadTask.pick({
|
||||
instanceId: true,
|
||||
torrentHash: true,
|
||||
infohashV1: true,
|
||||
infohashV2: true,
|
||||
acquisitionPlanId: true,
|
||||
sourceReleaseId: true,
|
||||
name: true,
|
||||
savePath: true,
|
||||
category: true,
|
||||
tags: true,
|
||||
managed: true,
|
||||
origin: true,
|
||||
}).partial({
|
||||
infohashV1: true,
|
||||
infohashV2: true,
|
||||
acquisitionPlanId: true,
|
||||
sourceReleaseId: true,
|
||||
savePath: true,
|
||||
category: true,
|
||||
tags: true,
|
||||
managed: true,
|
||||
origin: true,
|
||||
});
|
||||
export type DownloadTaskCreate = z.infer<typeof DownloadTaskCreate>;
|
||||
export const DownloadTaskUpdate = DownloadTask.omit({
|
||||
...SERVER_MANAGED,
|
||||
instanceId: true,
|
||||
torrentHash: true,
|
||||
}).partial();
|
||||
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
|
||||
|
||||
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
|
||||
export type OperationPhase = z.infer<typeof OperationPhase>;
|
||||
export const OperationKind = z.enum([
|
||||
"copy",
|
||||
"move",
|
||||
"rename",
|
||||
"mkdir",
|
||||
"hardlink",
|
||||
"delete_source",
|
||||
"delete_target",
|
||||
"verify",
|
||||
"register",
|
||||
"qb_pause",
|
||||
"qb_resume",
|
||||
"qb_remove",
|
||||
"qb_set_tags",
|
||||
"other",
|
||||
]);
|
||||
export type OperationKind = z.infer<typeof OperationKind>;
|
||||
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
|
||||
export type OperationStatus = z.infer<typeof OperationStatus>;
|
||||
|
||||
/** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */
|
||||
export const AssetOperation = EntityBase.extend({
|
||||
operationId: z.string().min(1),
|
||||
taskId: Id.nullable(),
|
||||
organizationRecordId: Id.nullable(),
|
||||
planFingerprint: z.string().nullable(),
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
phase: OperationPhase,
|
||||
kind: OperationKind,
|
||||
status: OperationStatus,
|
||||
intent: JsonObject,
|
||||
attemptToken: Id.nullable(),
|
||||
attempts: NonNegInt,
|
||||
result: JsonObject.nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
mediaAssetId: Id.nullable(),
|
||||
downloadTaskId: Id.nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type AssetOperation = z.infer<typeof AssetOperation>;
|
||||
export const AssetOperationCreate = AssetOperation.pick({
|
||||
operationId: true,
|
||||
taskId: true,
|
||||
organizationRecordId: true,
|
||||
planFingerprint: true,
|
||||
ordinal: true,
|
||||
phase: true,
|
||||
kind: true,
|
||||
intent: true,
|
||||
mediaAssetId: true,
|
||||
downloadTaskId: true,
|
||||
}).partial({
|
||||
taskId: true,
|
||||
organizationRecordId: true,
|
||||
planFingerprint: true,
|
||||
ordinal: true,
|
||||
intent: true,
|
||||
mediaAssetId: true,
|
||||
downloadTaskId: true,
|
||||
});
|
||||
export type AssetOperationCreate = z.infer<typeof AssetOperationCreate>;
|
||||
export const AssetOperationUpdate = AssetOperation.pick({
|
||||
status: true,
|
||||
attemptToken: true,
|
||||
attempts: true,
|
||||
result: true,
|
||||
error: true,
|
||||
mediaAssetId: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type AssetOperationUpdate = z.infer<typeof AssetOperationUpdate>;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod";
|
||||
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common";
|
||||
|
||||
// 身份域:外部身份映射与修订(REQ §7.2)
|
||||
// provider + entityType + externalId 对 accepted 唯一;TMDB movie:123 与 tv:123 是两个身份。
|
||||
|
||||
export const IdentityStatus = z.enum(["accepted", "proposed", "conflicted", "retired"]);
|
||||
export type IdentityStatus = z.infer<typeof IdentityStatus>;
|
||||
export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]);
|
||||
export type ExternalEntityType = z.infer<typeof ExternalEntityType>;
|
||||
/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */
|
||||
export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]);
|
||||
export type EvidenceLevel = z.infer<typeof EvidenceLevel>;
|
||||
export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]);
|
||||
export type IdentityVerifyStatus = z.infer<typeof IdentityVerifyStatus>;
|
||||
export const IdentityProvider = z.string().regex(/^[a-z][a-z0-9_]*$/);
|
||||
|
||||
export const ExternalIdentity = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
provider: IdentityProvider,
|
||||
entityType: ExternalEntityType,
|
||||
externalId: z.string().min(1),
|
||||
status: IdentityStatus,
|
||||
evidenceLevel: EvidenceLevel,
|
||||
evidence: JsonObject,
|
||||
/** 人工锁:自动匹配/刷新不得改动 */
|
||||
locked: z.boolean(),
|
||||
revision: z.number().int().min(1),
|
||||
validFrom: Timestamp.nullable(),
|
||||
validUntil: Timestamp.nullable(),
|
||||
verifiedAt: Timestamp.nullable(),
|
||||
/** 404/超时/鉴权失败只记录到这里,不是删除映射的依据 */
|
||||
verifyStatus: IdentityVerifyStatus,
|
||||
/** 附属字段,如 TMDB episode_group */
|
||||
extra: JsonObject,
|
||||
});
|
||||
export type ExternalIdentity = z.infer<typeof ExternalIdentity>;
|
||||
|
||||
const oneTarget = (v: { mediaEntityId?: string | null; contentUnitId?: string | null }) =>
|
||||
Number(!!v.mediaEntityId) + Number(!!v.contentUnitId) === 1;
|
||||
|
||||
export const ExternalIdentityCreate = ExternalIdentity.pick({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
provider: true,
|
||||
entityType: true,
|
||||
externalId: true,
|
||||
status: true,
|
||||
evidenceLevel: true,
|
||||
evidence: true,
|
||||
locked: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
extra: true,
|
||||
})
|
||||
.partial({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
status: true,
|
||||
evidenceLevel: true,
|
||||
evidence: true,
|
||||
locked: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
extra: true,
|
||||
})
|
||||
.refine(oneTarget, { message: "mediaEntityId 与 contentUnitId 必须且只能给一个" });
|
||||
export type ExternalIdentityCreate = z.infer<typeof ExternalIdentityCreate>;
|
||||
|
||||
/** 变更一律生成 identity_revision;expectedRevision 用于乐观并发 */
|
||||
export const ExternalIdentityUpdate = z.object({
|
||||
expectedRevision: z.number().int().min(1),
|
||||
status: IdentityStatus.optional(),
|
||||
evidenceLevel: EvidenceLevel.optional(),
|
||||
evidence: JsonObject.optional(),
|
||||
locked: z.boolean().optional(),
|
||||
validUntil: Timestamp.nullable().optional(),
|
||||
extra: JsonObject.optional(),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
export type ExternalIdentityUpdate = z.infer<typeof ExternalIdentityUpdate>;
|
||||
|
||||
export const IdentityRevisionAction = z.enum([
|
||||
"created",
|
||||
"status_changed",
|
||||
"relinked",
|
||||
"evidence_updated",
|
||||
"locked",
|
||||
"unlocked",
|
||||
]);
|
||||
export type IdentityRevisionAction = z.infer<typeof IdentityRevisionAction>;
|
||||
|
||||
/** 身份修订历史:只追加,不修改 */
|
||||
export const IdentityRevision = EntityBase.extend({
|
||||
externalIdentityId: Id,
|
||||
revision: z.number().int().min(1),
|
||||
action: IdentityRevisionAction,
|
||||
fromStatus: IdentityStatus.nullable(),
|
||||
toStatus: IdentityStatus.nullable(),
|
||||
before: JsonObject.nullable(),
|
||||
after: JsonObject,
|
||||
actorType: ActorType,
|
||||
actorId: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type IdentityRevision = z.infer<typeof IdentityRevision>;
|
||||
/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */
|
||||
export const IdentityRevisionCreate = IdentityRevision.pick({
|
||||
externalIdentityId: true,
|
||||
revision: true,
|
||||
action: true,
|
||||
fromStatus: true,
|
||||
toStatus: true,
|
||||
before: true,
|
||||
after: true,
|
||||
actorType: true,
|
||||
actorId: true,
|
||||
reason: true,
|
||||
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
|
||||
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
|
||||
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
|
||||
|
||||
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
|
||||
export type InstanceHealth = z.infer<typeof InstanceHealth>;
|
||||
|
||||
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
|
||||
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
|
||||
|
||||
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
|
||||
export const ManagementMode = z.enum(["register_only", "managed"]);
|
||||
export type ManagementMode = z.infer<typeof ManagementMode>;
|
||||
|
||||
export const LibraryRoot = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
mediaKind: LibraryMediaKind,
|
||||
/** 执行进程可访问的绝对路径 */
|
||||
localPath: z.string().startsWith("/"),
|
||||
managementMode: ManagementMode,
|
||||
enabled: z.boolean(),
|
||||
sentinelFile: z.string().nullable(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastHealthAt: Timestamp.nullable(),
|
||||
healthDetail: JsonObject,
|
||||
notes: z.string().nullable(),
|
||||
});
|
||||
export type LibraryRoot = z.infer<typeof LibraryRoot>;
|
||||
export const LibraryRootCreate = LibraryRoot.pick({
|
||||
name: true,
|
||||
mediaKind: true,
|
||||
localPath: true,
|
||||
managementMode: true,
|
||||
enabled: true,
|
||||
sentinelFile: true,
|
||||
notes: true,
|
||||
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
|
||||
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
|
||||
export const LibraryRootUpdate = LibraryRootCreate.partial();
|
||||
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
|
||||
|
||||
/** 只写凭证:明文仅在请求中出现,绝不回显 */
|
||||
const Credential = z.string().min(1).max(4096);
|
||||
|
||||
export const DownloaderKind = z.enum(["qbittorrent"]);
|
||||
export type DownloaderKind = z.infer<typeof DownloaderKind>;
|
||||
|
||||
export const DownloaderInstance = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
kind: DownloaderKind,
|
||||
baseUrl: z.url(),
|
||||
username: z.string().nullable(),
|
||||
secretRef: Id.nullable(),
|
||||
/** 管理标签名(REQ §8.4) */
|
||||
managedTag: z.string().min(1),
|
||||
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
|
||||
cacheVolumeKey: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastSeenAt: Timestamp.nullable(),
|
||||
versionInfo: JsonObject,
|
||||
settings: JsonObject,
|
||||
});
|
||||
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
|
||||
export const DownloaderInstanceCreate = DownloaderInstance.pick({
|
||||
name: true,
|
||||
kind: true,
|
||||
baseUrl: true,
|
||||
username: true,
|
||||
managedTag: true,
|
||||
cacheVolumeKey: true,
|
||||
enabled: true,
|
||||
settings: true,
|
||||
})
|
||||
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
|
||||
.extend({ credential: Credential.optional() });
|
||||
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
|
||||
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
|
||||
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
|
||||
|
||||
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
|
||||
export type MediaServerKind = z.infer<typeof MediaServerKind>;
|
||||
|
||||
export const MediaServerInstance = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
kind: MediaServerKind,
|
||||
baseUrl: z.url(),
|
||||
userId: z.string().nullable(),
|
||||
secretRef: Id.nullable(),
|
||||
enabled: z.boolean(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastSeenAt: Timestamp.nullable(),
|
||||
versionInfo: JsonObject,
|
||||
settings: JsonObject,
|
||||
});
|
||||
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
|
||||
export const MediaServerInstanceCreate = MediaServerInstance.pick({
|
||||
name: true,
|
||||
kind: true,
|
||||
baseUrl: true,
|
||||
userId: true,
|
||||
enabled: true,
|
||||
settings: true,
|
||||
})
|
||||
.partial({ kind: true, userId: true, enabled: true, settings: true })
|
||||
.extend({ credential: Credential.optional() });
|
||||
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
|
||||
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
|
||||
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
|
||||
|
||||
export const PathViewKind = z.enum(["downloader", "media_server"]);
|
||||
export type PathViewKind = z.infer<typeof PathViewKind>;
|
||||
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
|
||||
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
|
||||
|
||||
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
|
||||
export const PathMapping = EntityBase.extend({
|
||||
viewKind: PathViewKind,
|
||||
downloaderInstanceId: Id.nullable(),
|
||||
mediaServerInstanceId: Id.nullable(),
|
||||
libraryRootId: Id.nullable(),
|
||||
remotePrefix: z.string().min(1),
|
||||
localPrefix: z.string().startsWith("/"),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
verifyStatus: PathMappingVerifyStatus,
|
||||
verifiedAt: Timestamp.nullable(),
|
||||
verifyDetail: JsonObject,
|
||||
});
|
||||
export type PathMapping = z.infer<typeof PathMapping>;
|
||||
|
||||
const pathMappingOwnerOk = (v: {
|
||||
viewKind?: PathViewKind;
|
||||
downloaderInstanceId?: string | null;
|
||||
mediaServerInstanceId?: string | null;
|
||||
}) =>
|
||||
v.viewKind === undefined ||
|
||||
(v.viewKind === "downloader"
|
||||
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
|
||||
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
|
||||
|
||||
const PathMappingInput = PathMapping.omit({
|
||||
...SERVER_MANAGED,
|
||||
verifyStatus: true,
|
||||
verifiedAt: true,
|
||||
verifyDetail: true,
|
||||
});
|
||||
export const PathMappingCreate = PathMappingInput.partial({
|
||||
downloaderInstanceId: true,
|
||||
mediaServerInstanceId: true,
|
||||
libraryRootId: true,
|
||||
priority: true,
|
||||
enabled: true,
|
||||
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
|
||||
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
|
||||
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
|
||||
message: "viewKind 与实例 ID 不匹配",
|
||||
});
|
||||
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { z } from "zod";
|
||||
import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2)
|
||||
|
||||
export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]);
|
||||
export type ReconcileKind = z.infer<typeof ReconcileKind>;
|
||||
/** read_only:恢复后默认只读对账模式(REQ §8.5) */
|
||||
export const ReconcileMode = z.enum(["normal", "read_only"]);
|
||||
export type ReconcileMode = z.infer<typeof ReconcileMode>;
|
||||
export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
|
||||
export type ReconcileStatus = z.infer<typeof ReconcileStatus>;
|
||||
|
||||
export const ReconcileRun = EntityBase.extend({
|
||||
kind: ReconcileKind,
|
||||
mode: ReconcileMode,
|
||||
status: ReconcileStatus,
|
||||
scope: JsonObject,
|
||||
/** 中断续扫位置 */
|
||||
resumeCursor: JsonObject,
|
||||
stats: JsonObject,
|
||||
taskId: Id.nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type ReconcileRun = z.infer<typeof ReconcileRun>;
|
||||
export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({
|
||||
mode: true,
|
||||
scope: true,
|
||||
});
|
||||
export type ReconcileRunCreate = z.infer<typeof ReconcileRunCreate>;
|
||||
export const ReconcileRunUpdate = ReconcileRun.pick({
|
||||
status: true,
|
||||
resumeCursor: true,
|
||||
stats: true,
|
||||
taskId: true,
|
||||
error: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type ReconcileRunUpdate = z.infer<typeof ReconcileRunUpdate>;
|
||||
|
||||
export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]);
|
||||
export type ServerItemType = z.infer<typeof ServerItemType>;
|
||||
/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */
|
||||
export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]);
|
||||
export type ServerMatchStatus = z.infer<typeof ServerMatchStatus>;
|
||||
|
||||
/** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */
|
||||
export const ServerMapping = EntityBase.extend({
|
||||
mediaServerInstanceId: Id,
|
||||
serverItemId: z.string().min(1),
|
||||
itemType: ServerItemType,
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
mediaAssetId: Id.nullable(),
|
||||
serverPath: z.string().nullable(),
|
||||
pathMappingId: Id.nullable(),
|
||||
matchStatus: ServerMatchStatus,
|
||||
versionInfo: JsonObject,
|
||||
evidence: JsonObject,
|
||||
observedAt: Timestamp,
|
||||
});
|
||||
export type ServerMapping = z.infer<typeof ServerMapping>;
|
||||
export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
mediaAssetId: true,
|
||||
serverPath: true,
|
||||
pathMappingId: true,
|
||||
matchStatus: true,
|
||||
versionInfo: true,
|
||||
evidence: true,
|
||||
observedAt: true,
|
||||
});
|
||||
export type ServerMappingCreate = z.infer<typeof ServerMappingCreate>;
|
||||
export const ServerMappingUpdate = ServerMappingCreate.omit({
|
||||
mediaServerInstanceId: true,
|
||||
serverItemId: true,
|
||||
}).partial();
|
||||
export type ServerMappingUpdate = z.infer<typeof ServerMappingUpdate>;
|
||||
|
||||
/** API Key(不含 key_hash) */
|
||||
export const ApiKey = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
scopes: z.array(ApiScope),
|
||||
revokedAt: Timestamp.nullable(),
|
||||
lastUsedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type ApiKey = z.infer<typeof ApiKey>;
|
||||
export const ApiKeyCreate = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
scopes: z.array(ApiScope).min(1),
|
||||
});
|
||||
export type ApiKeyCreate = z.infer<typeof ApiKeyCreate>;
|
||||
/** 创建响应:明文 key 只在此返回一次 */
|
||||
export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) });
|
||||
export type ApiKeyCreated = z.infer<typeof ApiKeyCreated>;
|
||||
export const ApiKeyUpdate = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
scopes: z.array(ApiScope).min(1).optional(),
|
||||
revoked: z.literal(true).optional(),
|
||||
});
|
||||
export type ApiKeyUpdate = z.infer<typeof ApiKeyUpdate>;
|
||||
|
||||
/** 审计事件(只追加) */
|
||||
export const AuditEvent = z.object({
|
||||
id: Id,
|
||||
at: Timestamp,
|
||||
actorType: z.string(),
|
||||
actorId: z.string().nullable(),
|
||||
action: z.string().min(1),
|
||||
targetType: z.string().nullable(),
|
||||
targetId: z.string().nullable(),
|
||||
payload: JsonObject,
|
||||
traceId: z.string().nullable(),
|
||||
});
|
||||
export type AuditEvent = z.infer<typeof AuditEvent>;
|
||||
export const AuditEventCreate = AuditEvent.omit({ id: true, at: true })
|
||||
.partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true })
|
||||
.extend({ actorType: ActorType });
|
||||
export type AuditEventCreate = z.infer<typeof AuditEventCreate>;
|
||||
|
||||
export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]);
|
||||
export type OutboxStatus = z.infer<typeof OutboxStatus>;
|
||||
/** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */
|
||||
export const OutboxEvent = z.object({
|
||||
id: Id,
|
||||
at: Timestamp,
|
||||
type: z.string().min(1),
|
||||
payload: JsonObject,
|
||||
publishedAt: Timestamp.nullable(),
|
||||
idempotencyKey: z.string().nullable(),
|
||||
status: OutboxStatus,
|
||||
attempts: NonNegInt,
|
||||
nextAttemptAt: Timestamp,
|
||||
lastError: JsonObject.nullable(),
|
||||
});
|
||||
export type OutboxEvent = z.infer<typeof OutboxEvent>;
|
||||
export const OutboxEventCreate = OutboxEvent.pick({
|
||||
type: true,
|
||||
payload: true,
|
||||
idempotencyKey: true,
|
||||
nextAttemptAt: true,
|
||||
}).partial({
|
||||
payload: true,
|
||||
idempotencyKey: true,
|
||||
nextAttemptAt: true,
|
||||
});
|
||||
export type OutboxEventCreate = z.infer<typeof OutboxEventCreate>;
|
||||
export const OutboxEventUpdate = OutboxEvent.pick({
|
||||
status: true,
|
||||
attempts: true,
|
||||
nextAttemptAt: true,
|
||||
lastError: true,
|
||||
publishedAt: true,
|
||||
}).partial();
|
||||
export type OutboxEventUpdate = z.infer<typeof OutboxEventUpdate>;
|
||||
|
||||
export const SecretPurpose = z.enum([
|
||||
"site_cookie",
|
||||
"site_passkey",
|
||||
"site_api_key",
|
||||
"site_password",
|
||||
"feed_url",
|
||||
"downloader_password",
|
||||
"media_server_api_key",
|
||||
"metadata_api_key",
|
||||
"llm_api_key",
|
||||
"other",
|
||||
]);
|
||||
export type SecretPurpose = z.infer<typeof SecretPurpose>;
|
||||
|
||||
/** 加密凭证的元数据视图:密文/nonce 永不出 API */
|
||||
export const Secret = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
purpose: SecretPurpose,
|
||||
algorithm: z.string(),
|
||||
kdf: z.string(),
|
||||
kdfParams: JsonObject,
|
||||
keyVersion: z.number().int().min(1),
|
||||
rotatedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type Secret = z.infer<typeof Secret>;
|
||||
/** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */
|
||||
export const SecretCreate = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
purpose: SecretPurpose,
|
||||
plaintext: z.string().min(1).max(16384),
|
||||
});
|
||||
export type SecretCreate = z.infer<typeof SecretCreate>;
|
||||
export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) });
|
||||
export type SecretUpdate = z.infer<typeof SecretUpdate>;
|
||||
@@ -0,0 +1,288 @@
|
||||
import { z } from "zod";
|
||||
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common";
|
||||
import { MediaKind } from "./content";
|
||||
|
||||
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
|
||||
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
|
||||
|
||||
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
|
||||
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
|
||||
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
|
||||
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
|
||||
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */
|
||||
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]);
|
||||
export type OrganizationAction = z.infer<typeof OrganizationAction>;
|
||||
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
|
||||
export const SourcePathView = z.enum(["downloader", "local", "library"]);
|
||||
export type SourcePathView = z.infer<typeof SourcePathView>;
|
||||
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
|
||||
export type FileRole = z.infer<typeof FileRole>;
|
||||
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
|
||||
export type FileResultStatus = z.infer<typeof FileResultStatus>;
|
||||
|
||||
/** 稳定变更游标:bigint 序号的十进制字符串 */
|
||||
export const ChangeSeq = z.number().int().nonnegative();
|
||||
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
|
||||
export type ChangeCursor = z.infer<typeof ChangeCursor>;
|
||||
|
||||
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
|
||||
|
||||
export const OrganizationRecord = EntityBase.extend({
|
||||
taskId: Id.nullable(),
|
||||
trigger: OrganizationTrigger,
|
||||
actorType: ActorType,
|
||||
actorId: z.string().nullable(),
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
downloadTaskId: Id.nullable(),
|
||||
sourceDownloaderInstanceId: Id.nullable(),
|
||||
sourcePathView: SourcePathView,
|
||||
sourcePath: z.string().nullable(),
|
||||
targetLibraryRootId: Id.nullable(),
|
||||
targetPath: z.string().nullable(),
|
||||
action: OrganizationAction,
|
||||
status: OrganizationStatus,
|
||||
reason: z.string().nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
/** 冻结的整理计划 */
|
||||
plan: JsonObject,
|
||||
planFingerprint: z.string().nullable(),
|
||||
fileTotal: NonNegInt,
|
||||
fileSucceeded: NonNegInt,
|
||||
fileFailed: NonNegInt,
|
||||
fileSkipped: NonNegInt,
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
changeSeq: ChangeSeq,
|
||||
});
|
||||
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
|
||||
export const OrganizationRecordCreate = OrganizationRecord.pick({
|
||||
taskId: true,
|
||||
trigger: true,
|
||||
actorType: true,
|
||||
actorId: true,
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
downloadTaskId: true,
|
||||
sourceDownloaderInstanceId: true,
|
||||
sourcePathView: true,
|
||||
sourcePath: true,
|
||||
targetLibraryRootId: true,
|
||||
targetPath: true,
|
||||
action: true,
|
||||
plan: true,
|
||||
planFingerprint: true,
|
||||
}).partial({
|
||||
taskId: true,
|
||||
actorId: true,
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
downloadTaskId: true,
|
||||
sourceDownloaderInstanceId: true,
|
||||
sourcePathView: true,
|
||||
sourcePath: true,
|
||||
targetLibraryRootId: true,
|
||||
targetPath: true,
|
||||
plan: true,
|
||||
planFingerprint: true,
|
||||
});
|
||||
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
|
||||
export const OrganizationRecordUpdate = OrganizationRecord.pick({
|
||||
status: true,
|
||||
reason: true,
|
||||
error: true,
|
||||
targetPath: true,
|
||||
fileTotal: true,
|
||||
fileSucceeded: true,
|
||||
fileFailed: true,
|
||||
fileSkipped: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
|
||||
|
||||
export const OrganizationFileResult = EntityBase.extend({
|
||||
organizationRecordId: Id,
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
assetOperationId: Id.nullable(),
|
||||
mediaAssetId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
role: FileRole,
|
||||
mode: OrganizationAction,
|
||||
sourcePath: z.string().min(1),
|
||||
sourceSizeBytes: NonNegInt.nullable(),
|
||||
targetLibraryRootId: Id.nullable(),
|
||||
targetPath: z.string().nullable(),
|
||||
status: FileResultStatus,
|
||||
skipReason: z.string().nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
checksum: z.string().nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
|
||||
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
|
||||
organizationRecordId: true,
|
||||
ordinal: true,
|
||||
assetOperationId: true,
|
||||
contentUnitId: true,
|
||||
role: true,
|
||||
mode: true,
|
||||
sourcePath: true,
|
||||
sourceSizeBytes: true,
|
||||
targetLibraryRootId: true,
|
||||
targetPath: true,
|
||||
}).partial({
|
||||
ordinal: true,
|
||||
assetOperationId: true,
|
||||
contentUnitId: true,
|
||||
role: true,
|
||||
sourceSizeBytes: true,
|
||||
targetLibraryRootId: true,
|
||||
targetPath: true,
|
||||
});
|
||||
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
|
||||
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
|
||||
assetOperationId: true,
|
||||
mediaAssetId: true,
|
||||
targetPath: true,
|
||||
status: true,
|
||||
skipReason: true,
|
||||
error: true,
|
||||
checksum: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
}).partial();
|
||||
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
|
||||
|
||||
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
|
||||
|
||||
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
|
||||
export const PublicReason = z.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string(),
|
||||
});
|
||||
export type PublicReason = z.infer<typeof PublicReason>;
|
||||
|
||||
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
|
||||
export const PublicPathRef = z.object({
|
||||
path: z.string().nullable(),
|
||||
view: z.enum(["downloader", "local", "library"]),
|
||||
downloaderInstanceId: Id.nullable(),
|
||||
libraryRootId: Id.nullable(),
|
||||
});
|
||||
export type PublicPathRef = z.infer<typeof PublicPathRef>;
|
||||
|
||||
export const PublicOrganizationFile = z.object({
|
||||
id: Id,
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
role: FileRole,
|
||||
mode: OrganizationAction,
|
||||
status: FileResultStatus,
|
||||
source: PublicPathRef,
|
||||
target: PublicPathRef,
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
mediaAssetId: Id.nullable(),
|
||||
operationId: Id.nullable(),
|
||||
skipReason: PublicReason.nullable(),
|
||||
failure: PublicReason.nullable(),
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
|
||||
|
||||
export const PublicOrganizationRecord = z.object({
|
||||
/** 稳定记录 ID */
|
||||
id: Id,
|
||||
taskId: Id.nullable(),
|
||||
/** 相关资产操作账本 ID */
|
||||
operationIds: z.array(Id),
|
||||
media: z
|
||||
.object({
|
||||
entityId: Id,
|
||||
kind: MediaKind,
|
||||
title: z.string(),
|
||||
year: z.number().int().nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
seasonNumber: z.number().int().nullable(),
|
||||
episodeNumber: z.number().int().nullable(),
|
||||
/** 已确认的外部身份,如 tmdb:movie:693134 */
|
||||
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
|
||||
})
|
||||
.nullable(),
|
||||
trigger: OrganizationTrigger,
|
||||
/** 手动 / 自动来源(agent 视为自动) */
|
||||
origin: z.enum(["manual", "auto"]),
|
||||
actor: z.object({ type: ActorType, id: z.string().nullable() }),
|
||||
action: OrganizationAction,
|
||||
status: OrganizationStatus,
|
||||
source: PublicPathRef,
|
||||
target: PublicPathRef,
|
||||
reason: PublicReason.nullable(),
|
||||
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
|
||||
/** includeFiles=false 时为 null */
|
||||
files: z.array(PublicOrganizationFile).nullable(),
|
||||
createdAt: Timestamp,
|
||||
updatedAt: Timestamp,
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
|
||||
changeSeq: ChangeCursor,
|
||||
});
|
||||
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
|
||||
|
||||
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
|
||||
export const OrganizationChangesQuery = z.object({
|
||||
after: ChangeCursor.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
|
||||
status: OrganizationStatus.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
});
|
||||
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
|
||||
|
||||
/**
|
||||
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
|
||||
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
|
||||
*/
|
||||
export const OrganizationChangesResponse = z.object({
|
||||
items: z.array(PublicOrganizationRecord),
|
||||
nextCursor: ChangeCursor,
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
|
||||
|
||||
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
|
||||
export const OrganizationListQuery = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
status: OrganizationStatus.optional(),
|
||||
trigger: OrganizationTrigger.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
});
|
||||
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
|
||||
|
||||
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
|
||||
export const OrganizeRequest = z
|
||||
.object({
|
||||
downloadTaskId: Id.optional(),
|
||||
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
|
||||
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
contentUnitId: Id.optional(),
|
||||
action: z.enum(["copy", "move"]),
|
||||
targetLibraryRootId: Id.optional(),
|
||||
/** true 只生成计划(organize.plan),不执行 */
|
||||
dryRun: z.boolean().default(false),
|
||||
})
|
||||
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
|
||||
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
|
||||
|
||||
export const OrganizeAccepted = z.object({
|
||||
recordId: Id.nullable(),
|
||||
taskId: Id,
|
||||
planId: Id.nullable(),
|
||||
});
|
||||
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 规则域:订阅、策略版本、收录 Slot、偏好标签与抑制记录(REQ §6)
|
||||
|
||||
export const PolicyKind = z.enum(["acquisition", "preference", "retention", "cleanup", "instance", "automation"]);
|
||||
export type PolicyKind = z.infer<typeof PolicyKind>;
|
||||
export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]);
|
||||
export type PolicyStatus = z.infer<typeof PolicyStatus>;
|
||||
|
||||
/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 policyKey 只有一个 published */
|
||||
export const PolicyRevision = EntityBase.extend({
|
||||
policyKey: z.string().min(1),
|
||||
revision: z.number().int().min(1),
|
||||
kind: PolicyKind,
|
||||
status: PolicyStatus,
|
||||
title: z.string().min(1),
|
||||
summary: z.string().nullable(),
|
||||
scope: JsonObject,
|
||||
/** 结构化规则数据;不是可执行脚本或工作流 DSL */
|
||||
rules: JsonObject,
|
||||
preview: JsonObject.nullable(),
|
||||
basedOnRevisionId: Id.nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
publishedAt: Timestamp.nullable(),
|
||||
publishedBy: z.string().nullable(),
|
||||
});
|
||||
export type PolicyRevision = z.infer<typeof PolicyRevision>;
|
||||
/** 新建总是草案;revision 由服务端分配 */
|
||||
export const PolicyRevisionCreate = PolicyRevision.pick({
|
||||
policyKey: true,
|
||||
kind: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
scope: true,
|
||||
rules: true,
|
||||
basedOnRevisionId: true,
|
||||
}).partial({ summary: true, scope: true, basedOnRevisionId: true });
|
||||
export type PolicyRevisionCreate = z.infer<typeof PolicyRevisionCreate>;
|
||||
/** 只有草案可改内容;发布/回滚走动作端点 */
|
||||
export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial();
|
||||
export type PolicyRevisionUpdate = z.infer<typeof PolicyRevisionUpdate>;
|
||||
|
||||
export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]);
|
||||
export type SubscriptionMediaKind = z.infer<typeof SubscriptionMediaKind>;
|
||||
export const SubscriptionMode = z.enum(["once", "continuous"]);
|
||||
export type SubscriptionMode = z.infer<typeof SubscriptionMode>;
|
||||
/** 仅本季 / 持续后续季(REQ §6.5) */
|
||||
export const SeasonScope = z.enum(["current_season", "following_seasons"]);
|
||||
export type SeasonScope = z.infer<typeof SeasonScope>;
|
||||
export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]);
|
||||
export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
|
||||
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
|
||||
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
|
||||
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
|
||||
export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]);
|
||||
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
|
||||
|
||||
export const Subscription = EntityBase.extend({
|
||||
mediaEntityId: Id.nullable(),
|
||||
seasonUnitId: Id.nullable(),
|
||||
/** 作品尚未建档时的检索词 */
|
||||
titleQuery: z.string().nullable(),
|
||||
mediaKind: SubscriptionMediaKind,
|
||||
mode: SubscriptionMode,
|
||||
seasonScope: SeasonScope,
|
||||
origin: SubscriptionOrigin,
|
||||
priority: AcquisitionPriority,
|
||||
status: SubscriptionStatus,
|
||||
policyRevisionId: Id.nullable(),
|
||||
params: JsonObject,
|
||||
statusReason: z.string().nullable(),
|
||||
lastCheckedAt: Timestamp.nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
});
|
||||
export type Subscription = z.infer<typeof Subscription>;
|
||||
export const SubscriptionCreate = Subscription.pick({
|
||||
mediaEntityId: true,
|
||||
seasonUnitId: true,
|
||||
titleQuery: true,
|
||||
mediaKind: true,
|
||||
mode: true,
|
||||
seasonScope: true,
|
||||
origin: true,
|
||||
priority: true,
|
||||
policyRevisionId: true,
|
||||
params: true,
|
||||
})
|
||||
.partial({
|
||||
mediaEntityId: true,
|
||||
seasonUnitId: true,
|
||||
titleQuery: true,
|
||||
mode: true,
|
||||
seasonScope: true,
|
||||
origin: true,
|
||||
priority: true,
|
||||
policyRevisionId: true,
|
||||
params: true,
|
||||
})
|
||||
.refine((v) => !!v.mediaEntityId || !!v.titleQuery, { message: "mediaEntityId 与 titleQuery 至少给一个" });
|
||||
export type SubscriptionCreate = z.infer<typeof SubscriptionCreate>;
|
||||
export const SubscriptionUpdate = Subscription.pick({
|
||||
mode: true,
|
||||
seasonScope: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
policyRevisionId: true,
|
||||
params: true,
|
||||
statusReason: true,
|
||||
}).partial();
|
||||
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
|
||||
|
||||
/** Slot 状态(REQ §6.1) */
|
||||
export const SlotStatus = z.enum([
|
||||
"empty",
|
||||
"reserved",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"satisfied",
|
||||
"upgrade_pending",
|
||||
"attention",
|
||||
]);
|
||||
export type SlotStatus = z.infer<typeof SlotStatus>;
|
||||
|
||||
/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */
|
||||
export const AcquisitionSlot = EntityBase.extend({
|
||||
mediaEntityId: Id,
|
||||
contentUnitId: Id.nullable(),
|
||||
subscriptionId: Id.nullable(),
|
||||
policyRevisionId: Id.nullable(),
|
||||
/** 用户命名,如 archival / streaming */
|
||||
name: z.string().min(1).max(64),
|
||||
status: SlotStatus,
|
||||
hardConditions: JsonObject,
|
||||
softPreferences: JsonObject,
|
||||
fallback: JsonObject,
|
||||
upgradeThreshold: JsonObject,
|
||||
completion: JsonObject,
|
||||
/** 同组 Slot 在 requireDistinctAsset=true 时必须由不同物理资产满足(M2 校验) */
|
||||
distinctGroup: z.string().nullable(),
|
||||
requireDistinctAsset: z.boolean(),
|
||||
currentAssetId: Id.nullable(),
|
||||
reservedPlanId: Id.nullable(),
|
||||
statusReason: z.string().nullable(),
|
||||
lastEvaluatedAt: Timestamp.nullable(),
|
||||
nextEvaluateAt: Timestamp.nullable(),
|
||||
revision: z.number().int().min(1),
|
||||
});
|
||||
export type AcquisitionSlot = z.infer<typeof AcquisitionSlot>;
|
||||
export const AcquisitionSlotCreate = AcquisitionSlot.pick({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
subscriptionId: true,
|
||||
policyRevisionId: true,
|
||||
name: true,
|
||||
hardConditions: true,
|
||||
softPreferences: true,
|
||||
fallback: true,
|
||||
upgradeThreshold: true,
|
||||
completion: true,
|
||||
distinctGroup: true,
|
||||
requireDistinctAsset: true,
|
||||
}).partial({
|
||||
contentUnitId: true,
|
||||
subscriptionId: true,
|
||||
policyRevisionId: true,
|
||||
hardConditions: true,
|
||||
softPreferences: true,
|
||||
fallback: true,
|
||||
upgradeThreshold: true,
|
||||
completion: true,
|
||||
distinctGroup: true,
|
||||
requireDistinctAsset: true,
|
||||
});
|
||||
export type AcquisitionSlotCreate = z.infer<typeof AcquisitionSlotCreate>;
|
||||
/** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */
|
||||
export const AcquisitionSlotUpdate = AcquisitionSlotCreate.omit({
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
name: true,
|
||||
})
|
||||
.partial()
|
||||
.extend({ expectedRevision: z.number().int().min(1) });
|
||||
export type AcquisitionSlotUpdate = z.infer<typeof AcquisitionSlotUpdate>;
|
||||
|
||||
/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */
|
||||
export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]);
|
||||
export type PreferenceKind = z.infer<typeof PreferenceKind>;
|
||||
export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]);
|
||||
export type PreferenceOrigin = z.infer<typeof PreferenceOrigin>;
|
||||
export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]);
|
||||
export type PreferenceStatus = z.infer<typeof PreferenceStatus>;
|
||||
|
||||
export const PreferenceTag = EntityBase.extend({
|
||||
label: z.string().min(1).max(80),
|
||||
kind: PreferenceKind,
|
||||
scope: JsonObject,
|
||||
rule: JsonObject,
|
||||
/** 语义指纹(kind+scope+rule 规范化哈希),用于抑制匹配 */
|
||||
fingerprint: z.string().min(1),
|
||||
origin: PreferenceOrigin,
|
||||
description: z.string().nullable(),
|
||||
status: PreferenceStatus,
|
||||
revision: z.number().int().min(1),
|
||||
evidence: JsonObject,
|
||||
policyRevisionId: Id.nullable(),
|
||||
});
|
||||
export type PreferenceTag = z.infer<typeof PreferenceTag>;
|
||||
/** fingerprint 由服务端计算 */
|
||||
export const PreferenceTagCreate = PreferenceTag.pick({
|
||||
label: true,
|
||||
kind: true,
|
||||
scope: true,
|
||||
rule: true,
|
||||
origin: true,
|
||||
description: true,
|
||||
evidence: true,
|
||||
}).partial({ scope: true, rule: true, origin: true, description: true, evidence: true });
|
||||
export type PreferenceTagCreate = z.infer<typeof PreferenceTagCreate>;
|
||||
export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true })
|
||||
.partial()
|
||||
.extend({ status: PreferenceStatus.optional() });
|
||||
export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
|
||||
|
||||
/** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */
|
||||
export const PreferenceSuppression = EntityBase.extend({
|
||||
preferenceTagId: Id.nullable(),
|
||||
fingerprint: z.string().min(1),
|
||||
reason: z.string().nullable(),
|
||||
evidence: JsonObject,
|
||||
suppressedBy: z.string().min(1),
|
||||
active: z.boolean(),
|
||||
liftedAt: Timestamp.nullable(),
|
||||
liftedBy: z.string().nullable(),
|
||||
});
|
||||
export type PreferenceSuppression = z.infer<typeof PreferenceSuppression>;
|
||||
export const PreferenceSuppressionCreate = PreferenceSuppression.omit({
|
||||
...SERVER_MANAGED,
|
||||
active: true,
|
||||
liftedAt: true,
|
||||
liftedBy: true,
|
||||
suppressedBy: true,
|
||||
}).partial({ preferenceTagId: true, reason: true, evidence: true });
|
||||
export type PreferenceSuppressionCreate = z.infer<typeof PreferenceSuppressionCreate>;
|
||||
/** 唯一允许的更新是解除抑制 */
|
||||
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
|
||||
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { z } from "zod";
|
||||
import { Id, Timestamp } from "./common";
|
||||
|
||||
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
|
||||
// schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值);
|
||||
// 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。
|
||||
|
||||
const Percent = z.number().min(1).max(100);
|
||||
|
||||
export const AutomationSettings = z.object({
|
||||
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
|
||||
enabled: z.boolean(),
|
||||
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
|
||||
readOnlyReconcile: z.boolean(),
|
||||
});
|
||||
export type AutomationSettings = z.infer<typeof AutomationSettings>;
|
||||
|
||||
const CacheShape = z.object({
|
||||
/** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */
|
||||
hardThresholdPercent: Percent,
|
||||
/** 恢复水位:降到此值以下恢复因阈值暂停的任务 */
|
||||
resumePercent: Percent,
|
||||
checkIntervalSec: z.number().int().min(10).max(3600),
|
||||
/** 缓存清理的目标使用率(低水位) */
|
||||
cleanupTargetPercent: Percent,
|
||||
});
|
||||
/** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */
|
||||
export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, {
|
||||
message: "恢复水位必须低于硬阈值",
|
||||
}).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" });
|
||||
export type CacheSettings = z.infer<typeof CacheSettings>;
|
||||
|
||||
export const OrganizeMode = z.enum(["copy", "move"]);
|
||||
export type OrganizeMode = z.infer<typeof OrganizeMode>;
|
||||
export const OrganizeSettings = z.object({
|
||||
defaultMode: OrganizeMode,
|
||||
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
|
||||
allowCrossVolumeMove: z.boolean(),
|
||||
});
|
||||
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
|
||||
|
||||
export const RetentionSettings = z.object({
|
||||
/** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */
|
||||
seedRetentionDays: z.number().int().min(0).max(365),
|
||||
/** 观察/日志类数据保留天数 */
|
||||
logRetentionDays: z.number().int().min(1).max(3650),
|
||||
});
|
||||
export type RetentionSettings = z.infer<typeof RetentionSettings>;
|
||||
|
||||
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
||||
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
|
||||
|
||||
/** Pi Agent 设置(REQ §10.5):上游/模型/思考档位/上下文预算/压缩/子代理;密钥只存 secret 引用 */
|
||||
export const PiSettings = z.object({
|
||||
provider: z.string().min(1),
|
||||
baseUrl: z.url().nullable(),
|
||||
model: z.string().min(1).nullable(),
|
||||
thinkingLevel: ThinkingLevel,
|
||||
/** 上下文预算(token) */
|
||||
contextBudget: z.number().int().min(4096).max(2_000_000),
|
||||
compaction: z.object({
|
||||
enabled: z.boolean(),
|
||||
/** 达到上下文预算的该比例时触发压缩 */
|
||||
thresholdRatio: z.number().min(0.3).max(0.95),
|
||||
}),
|
||||
subagentsEnabled: z.boolean(),
|
||||
apiKeySecretRef: Id.nullable(),
|
||||
});
|
||||
export type PiSettings = z.infer<typeof PiSettings>;
|
||||
|
||||
/** 每日预算与并发(REQ §6.4、§10.5) */
|
||||
export const BudgetSettings = z.object({
|
||||
maxNewTitlesPerDay: z.number().int().min(0),
|
||||
maxDownloadBytesPerDay: z.number().int().min(0),
|
||||
maxConcurrentDownloads: z.number().int().min(1).max(100),
|
||||
agentMaxRunsPerDay: z.number().int().min(0),
|
||||
agentMaxCostUsdPerDay: z.number().min(0),
|
||||
});
|
||||
export type BudgetSettings = z.infer<typeof BudgetSettings>;
|
||||
|
||||
export const GlobalSettings = z.object({
|
||||
automation: AutomationSettings,
|
||||
cache: CacheSettings,
|
||||
organize: OrganizeSettings,
|
||||
retention: RetentionSettings,
|
||||
pi: PiSettings,
|
||||
budget: BudgetSettings,
|
||||
});
|
||||
export type GlobalSettings = z.infer<typeof GlobalSettings>;
|
||||
|
||||
/** settings 表的合法 key */
|
||||
export const SettingsKey = GlobalSettings.keyof();
|
||||
export type SettingsKey = z.infer<typeof SettingsKey>;
|
||||
|
||||
/** 缺省设置(settings 表缺 key 或缺字段时使用) */
|
||||
export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
automation: { enabled: true, readOnlyReconcile: false },
|
||||
cache: { hardThresholdPercent: 90, resumePercent: 85, checkIntervalSec: 60, cleanupTargetPercent: 80 },
|
||||
organize: { defaultMode: "copy", allowCrossVolumeMove: true },
|
||||
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
|
||||
pi: {
|
||||
provider: "anthropic",
|
||||
baseUrl: null,
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
contextBudget: 128_000,
|
||||
compaction: { enabled: true, thresholdRatio: 0.8 },
|
||||
subagentsEnabled: false,
|
||||
apiKeySecretRef: null,
|
||||
},
|
||||
budget: {
|
||||
maxNewTitlesPerDay: 20,
|
||||
maxDownloadBytesPerDay: 500 * 1024 ** 3,
|
||||
maxConcurrentDownloads: 5,
|
||||
agentMaxRunsPerDay: 50,
|
||||
agentMaxCostUsdPerDay: 5,
|
||||
},
|
||||
};
|
||||
|
||||
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
|
||||
export const GlobalSettingsUpdate = z.object({
|
||||
automation: AutomationSettings.partial().optional(),
|
||||
cache: CacheShape.partial().optional(),
|
||||
organize: OrganizeSettings.partial().optional(),
|
||||
retention: RetentionSettings.partial().optional(),
|
||||
pi: PiSettings.omit({ apiKeySecretRef: true })
|
||||
.partial()
|
||||
.extend({
|
||||
compaction: PiSettings.shape.compaction.partial().optional(),
|
||||
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
|
||||
apiKey: z.string().min(1).max(4096).nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
budget: BudgetSettings.partial().optional(),
|
||||
});
|
||||
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
|
||||
|
||||
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
|
||||
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
|
||||
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
|
||||
return {
|
||||
automation: { ...current.automation, ...patch.automation },
|
||||
cache: { ...current.cache, ...patch.cache },
|
||||
organize: { ...current.organize, ...patch.organize },
|
||||
retention: { ...current.retention, ...patch.retention },
|
||||
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
|
||||
budget: { ...current.budget, ...patch.budget },
|
||||
};
|
||||
}
|
||||
|
||||
/** settings 表行 */
|
||||
export const SettingEntry = z.object({
|
||||
key: SettingsKey,
|
||||
value: z.unknown(),
|
||||
updatedAt: Timestamp,
|
||||
});
|
||||
export type SettingEntry = z.infer<typeof SettingEntry>;
|
||||
@@ -0,0 +1,261 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
|
||||
|
||||
// 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4)
|
||||
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
|
||||
|
||||
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "generic_rss"]);
|
||||
export type SiteAdapter = z.infer<typeof SiteAdapter>;
|
||||
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
|
||||
export type SiteHealth = z.infer<typeof SiteHealth>;
|
||||
export const CircuitState = z.enum(["closed", "open", "half_open"]);
|
||||
export type CircuitState = z.infer<typeof CircuitState>;
|
||||
|
||||
/** 站点级限流(快照、实时搜索、浏览器共享) */
|
||||
export const SiteRateLimit = z.object({
|
||||
requestsPerMinute: z.number().int().positive().optional(),
|
||||
minIntervalMs: z.number().int().nonnegative().optional(),
|
||||
concurrency: z.number().int().positive().optional(),
|
||||
});
|
||||
export type SiteRateLimit = z.infer<typeof SiteRateLimit>;
|
||||
|
||||
export const Site = EntityBase.extend({
|
||||
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
|
||||
name: z.string().min(1),
|
||||
adapter: SiteAdapter,
|
||||
baseUrl: z.url(),
|
||||
enabled: z.boolean(),
|
||||
rateLimit: SiteRateLimit,
|
||||
config: JsonObject,
|
||||
healthStatus: SiteHealth,
|
||||
circuitState: CircuitState,
|
||||
circuitOpenUntil: Timestamp.nullable(),
|
||||
snapshotEnabled: z.boolean(),
|
||||
snapshotIntervalSec: z.number().int().positive().nullable(),
|
||||
lastSnapshotAt: Timestamp.nullable(),
|
||||
snapshotCursor: JsonObject,
|
||||
});
|
||||
export type Site = z.infer<typeof Site>;
|
||||
export const SiteCreate = Site.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
adapter: true,
|
||||
baseUrl: true,
|
||||
enabled: true,
|
||||
rateLimit: true,
|
||||
config: true,
|
||||
snapshotEnabled: true,
|
||||
snapshotIntervalSec: true,
|
||||
}).partial({ enabled: true, rateLimit: true, config: true, snapshotEnabled: true, snapshotIntervalSec: true });
|
||||
export type SiteCreate = z.infer<typeof SiteCreate>;
|
||||
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
|
||||
export type SiteUpdate = z.infer<typeof SiteUpdate>;
|
||||
|
||||
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
|
||||
export type SiteAuthKind = z.infer<typeof SiteAuthKind>;
|
||||
export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]);
|
||||
export type SiteAccountStatus = z.infer<typeof SiteAccountStatus>;
|
||||
|
||||
export const SiteAccount = EntityBase.extend({
|
||||
siteId: Id,
|
||||
label: z.string().min(1),
|
||||
authKind: SiteAuthKind,
|
||||
secretRef: Id.nullable(),
|
||||
enabled: z.boolean(),
|
||||
status: SiteAccountStatus,
|
||||
lastVerifiedAt: Timestamp.nullable(),
|
||||
userInfo: JsonObject,
|
||||
});
|
||||
export type SiteAccount = z.infer<typeof SiteAccount>;
|
||||
export const SiteAccountCreate = SiteAccount.pick({ siteId: true, label: true, authKind: true, enabled: true })
|
||||
.partial({ enabled: true })
|
||||
.extend({ credential: z.string().min(1).max(16384) });
|
||||
export type SiteAccountCreate = z.infer<typeof SiteAccountCreate>;
|
||||
export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial();
|
||||
export type SiteAccountUpdate = z.infer<typeof SiteAccountUpdate>;
|
||||
|
||||
export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]);
|
||||
export type FeedPurpose = z.infer<typeof FeedPurpose>;
|
||||
export const FeedKind = z.enum(["rss", "atom", "list", "query"]);
|
||||
export type FeedKind = z.infer<typeof FeedKind>;
|
||||
export const FeedHealth = z.enum(["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"]);
|
||||
export type FeedHealth = z.infer<typeof FeedHealth>;
|
||||
|
||||
export const FeedSource = EntityBase.extend({
|
||||
siteId: Id.nullable(),
|
||||
siteAccountId: Id.nullable(),
|
||||
name: z.string().min(1),
|
||||
purpose: FeedPurpose,
|
||||
kind: FeedKind,
|
||||
urlSecretRef: Id.nullable(),
|
||||
/** 脱敏 URL(passkey 等参数替换为 [REDACTED]) */
|
||||
urlDisplay: z.string(),
|
||||
parserVersion: z.string(),
|
||||
enabled: z.boolean(),
|
||||
pollIntervalSec: z.number().int().min(60),
|
||||
filter: JsonObject,
|
||||
cursor: JsonObject,
|
||||
etag: z.string().nullable(),
|
||||
lastModified: z.string().nullable(),
|
||||
lastFetchAt: Timestamp.nullable(),
|
||||
lastSuccessAt: Timestamp.nullable(),
|
||||
nextFetchAt: Timestamp.nullable(),
|
||||
consecutiveFailures: NonNegInt,
|
||||
backoffUntil: Timestamp.nullable(),
|
||||
healthStatus: FeedHealth,
|
||||
lastError: JsonObject.nullable(),
|
||||
});
|
||||
export type FeedSource = z.infer<typeof FeedSource>;
|
||||
/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */
|
||||
export const FeedSourceCreate = FeedSource.pick({
|
||||
siteId: true,
|
||||
siteAccountId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
kind: true,
|
||||
enabled: true,
|
||||
pollIntervalSec: true,
|
||||
filter: true,
|
||||
})
|
||||
.partial({
|
||||
siteId: true,
|
||||
siteAccountId: true,
|
||||
purpose: true,
|
||||
kind: true,
|
||||
enabled: true,
|
||||
pollIntervalSec: true,
|
||||
filter: true,
|
||||
})
|
||||
.extend({ url: z.url() });
|
||||
export type FeedSourceCreate = z.infer<typeof FeedSourceCreate>;
|
||||
export const FeedSourceUpdate = FeedSourceCreate.partial();
|
||||
export type FeedSourceUpdate = z.infer<typeof FeedSourceUpdate>;
|
||||
|
||||
export const ResolutionStatus = z.enum(["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"]);
|
||||
export type ResolutionStatus = z.infer<typeof ResolutionStatus>;
|
||||
export const ReleaseAvailability = z.enum(["active", "site_disabled", "expired", "unknown"]);
|
||||
export type ReleaseAvailability = z.infer<typeof ReleaseAvailability>;
|
||||
|
||||
/** 促销状态必须带观察时间与过期时间,下单前按策略复核(REQ §4.3) */
|
||||
export const Promotion = z.object({
|
||||
kind: z.string().optional(),
|
||||
observedAt: Timestamp.optional(),
|
||||
expiresAt: Timestamp.nullable().optional(),
|
||||
raw: z.unknown().optional(),
|
||||
});
|
||||
export type Promotion = z.infer<typeof Promotion>;
|
||||
|
||||
export const TorrentFileEntry = z.object({
|
||||
path: z.string(),
|
||||
size: NonNegInt,
|
||||
});
|
||||
export type TorrentFileEntry = z.infer<typeof TorrentFileEntry>;
|
||||
|
||||
export const SourceRelease = EntityBase.extend({
|
||||
siteId: Id,
|
||||
siteReleaseId: z.string().min(1),
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
categoryRaw: z.string().nullable(),
|
||||
tagsRaw: z.array(z.string()),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
infohashV1: z.string().nullable(),
|
||||
infohashV2: z.string().nullable(),
|
||||
fileList: z.array(TorrentFileEntry).nullable(),
|
||||
promotion: Promotion,
|
||||
promotionExpiresAt: Timestamp.nullable(),
|
||||
seeders: z.number().int().nullable(),
|
||||
leechers: z.number().int().nullable(),
|
||||
/** 站点发布时间;区别于 firstSeenAt(本系统首次发现) */
|
||||
sourcePublishedAt: Timestamp.nullable(),
|
||||
firstSeenAt: Timestamp,
|
||||
lastSeenAt: Timestamp,
|
||||
detailFetchedAt: Timestamp.nullable(),
|
||||
/** 标题解析结果(标题/年份/季集/分辨率/来源/编码/发布组…) */
|
||||
parsed: JsonObject,
|
||||
parserVersion: z.string().nullable(),
|
||||
mediaEntityId: Id.nullable(),
|
||||
contentUnitId: Id.nullable(),
|
||||
resolutionStatus: ResolutionStatus,
|
||||
resolutionEvidence: JsonObject,
|
||||
availability: ReleaseAvailability,
|
||||
raw: JsonObject,
|
||||
});
|
||||
export type SourceRelease = z.infer<typeof SourceRelease>;
|
||||
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
|
||||
export const SourceReleaseCreate = SourceRelease.omit({
|
||||
...SERVER_MANAGED,
|
||||
firstSeenAt: true,
|
||||
lastSeenAt: true,
|
||||
}).partial({
|
||||
subtitle: true,
|
||||
categoryRaw: true,
|
||||
tagsRaw: true,
|
||||
sizeBytes: true,
|
||||
infohashV1: true,
|
||||
infohashV2: true,
|
||||
fileList: true,
|
||||
promotion: true,
|
||||
promotionExpiresAt: true,
|
||||
seeders: true,
|
||||
leechers: true,
|
||||
sourcePublishedAt: true,
|
||||
detailFetchedAt: true,
|
||||
parsed: true,
|
||||
parserVersion: true,
|
||||
mediaEntityId: true,
|
||||
contentUnitId: true,
|
||||
resolutionStatus: true,
|
||||
resolutionEvidence: true,
|
||||
availability: true,
|
||||
raw: true,
|
||||
});
|
||||
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
|
||||
export const SourceReleaseUpdate = SourceReleaseCreate.omit({ siteId: true, siteReleaseId: true }).partial();
|
||||
export type SourceReleaseUpdate = z.infer<typeof SourceReleaseUpdate>;
|
||||
|
||||
export const ObservationStatus = z.enum(["new", "linked", "ignored", "error"]);
|
||||
export type ObservationStatus = z.infer<typeof ObservationStatus>;
|
||||
|
||||
export const Observation = EntityBase.extend({
|
||||
feedId: Id,
|
||||
guid: z.string().min(1),
|
||||
contentDigest: z.string().min(1),
|
||||
sourceReleaseId: Id.nullable(),
|
||||
title: z.string(),
|
||||
/** 已脱敏的链接;原始下载链接不对外 */
|
||||
linkRedacted: z.string().nullable(),
|
||||
raw: JsonObject,
|
||||
sourcePublishedAt: Timestamp.nullable(),
|
||||
fetchedAt: Timestamp,
|
||||
firstSeenAt: Timestamp,
|
||||
lastSeenAt: Timestamp,
|
||||
status: ObservationStatus,
|
||||
resolveAttempts: NonNegInt,
|
||||
nextResolveAt: Timestamp.nullable(),
|
||||
lastError: JsonObject.nullable(),
|
||||
});
|
||||
export type Observation = z.infer<typeof Observation>;
|
||||
export const ObservationCreate = Observation.pick({
|
||||
feedId: true,
|
||||
guid: true,
|
||||
contentDigest: true,
|
||||
sourceReleaseId: true,
|
||||
title: true,
|
||||
linkRedacted: true,
|
||||
raw: true,
|
||||
sourcePublishedAt: true,
|
||||
fetchedAt: true,
|
||||
}).partial({ sourceReleaseId: true, linkRedacted: true, raw: true, sourcePublishedAt: true, fetchedAt: true });
|
||||
export type ObservationCreate = z.infer<typeof ObservationCreate>;
|
||||
export const ObservationUpdate = Observation.pick({
|
||||
contentDigest: true,
|
||||
sourceReleaseId: true,
|
||||
title: true,
|
||||
raw: true,
|
||||
status: true,
|
||||
resolveAttempts: true,
|
||||
nextResolveAt: true,
|
||||
lastError: true,
|
||||
}).partial();
|
||||
export type ObservationUpdate = z.infer<typeof ObservationUpdate>;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import * as C from "../src";
|
||||
|
||||
// 与 packages/db/src/types.ts(从迁移反射生成)做文本级对照:
|
||||
// 1) 每张表的列 ⇔ 对应 Row schema 的字段(camelCase),只允许显式列出的差异;
|
||||
// 2) 每个 CHECK 枚举 ⇔ contracts 中对应 z.enum 的取值。
|
||||
// 只读文件,不引入 @umt/db 依赖。
|
||||
|
||||
const typesSrc = readFileSync(new URL("../../db/src/types.ts", import.meta.url), "utf8");
|
||||
|
||||
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||
|
||||
function tableColumns(): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const m of typesSrc.matchAll(/export interface (\w+)Table \{\n([\s\S]*?)\n\}/g)) {
|
||||
const cols = [...m[2]!.matchAll(/^ {2}(\w+):/gm)].map((x) => x[1]!);
|
||||
out.set(m[1]!, cols);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function enumAliases(): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const m of typesSrc.matchAll(/export type (\w+) =([^;]+);/g)) {
|
||||
if (m[1] === "Timestamptz" || m[1] === "Json" || m[2]!.includes("<")) continue;
|
||||
out.set(
|
||||
m[1]!,
|
||||
[...m[2]!.matchAll(/"([^"]*)"/g)].map((x) => x[1]!),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Obj = z.ZodObject;
|
||||
|
||||
/** 表(PascalCase)→ Row schema 与允许的差异 */
|
||||
const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }> = {
|
||||
AcquisitionPlan: { row: C.AcquisitionPlan },
|
||||
AcquisitionSlot: { row: C.AcquisitionSlot },
|
||||
AgentRun: { row: C.AgentRun },
|
||||
ApiKey: { row: C.ApiKey, dbOnly: ["keyHash"] },
|
||||
Approval: { row: C.Approval },
|
||||
Artifact: { row: C.Artifact },
|
||||
AssetLocation: { row: C.AssetLocation },
|
||||
AssetOperation: { row: C.AssetOperation },
|
||||
AssetUnitCoverage: { row: C.AssetUnitCoverage },
|
||||
AuditEvent: { row: C.AuditEvent },
|
||||
AutomationGrant: { row: C.AutomationGrant },
|
||||
ContentUnit: { row: C.ContentUnit },
|
||||
Decision: { row: C.Decision },
|
||||
DownloadTask: { row: C.DownloadTask },
|
||||
DownloaderInstance: { row: C.DownloaderInstance },
|
||||
ExternalIdentity: { row: C.ExternalIdentity },
|
||||
FeedSource: { row: C.FeedSource },
|
||||
IdentityRevision: { row: C.IdentityRevision },
|
||||
LibraryRoot: { row: C.LibraryRoot },
|
||||
MediaAsset: { row: C.MediaAsset },
|
||||
MediaEntity: { row: C.MediaEntity },
|
||||
MediaServerInstance: { row: C.MediaServerInstance },
|
||||
Observation: { row: C.Observation },
|
||||
OrganizationFileResult: { row: C.OrganizationFileResult },
|
||||
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
|
||||
OutboxEvent: { row: C.OutboxEvent },
|
||||
PathBinding: { row: C.PathBinding },
|
||||
PathMapping: { row: C.PathMapping },
|
||||
PolicyRevision: { row: C.PolicyRevision },
|
||||
PreferenceSuppression: { row: C.PreferenceSuppression },
|
||||
PreferenceTag: { row: C.PreferenceTag },
|
||||
ReconcileRun: { row: C.ReconcileRun },
|
||||
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
|
||||
ServerMapping: { row: C.ServerMapping },
|
||||
Settings: { row: C.SettingEntry },
|
||||
Site: { row: C.Site },
|
||||
SiteAccount: { row: C.SiteAccount },
|
||||
SourceRelease: { row: C.SourceRelease },
|
||||
Subscription: { row: C.Subscription },
|
||||
Task: { row: C.Task },
|
||||
TitleAlias: { row: C.TitleAlias },
|
||||
};
|
||||
|
||||
/** DB 枚举类型名 → contracts 枚举 */
|
||||
const ENUMS: Record<string, z.ZodEnum> = {
|
||||
AcquisitionPlanPurpose: C.PlanPurpose,
|
||||
AcquisitionPlanStatus: C.PlanStatus,
|
||||
AcquisitionPlanPriority: C.AcquisitionPriority,
|
||||
AcquisitionSlotStatus: C.SlotStatus,
|
||||
AgentRunTrigger: C.AgentTrigger,
|
||||
AgentRunStatus: C.AgentRunStatus,
|
||||
ApprovalStatus: C.ApprovalStatus,
|
||||
ArtifactKind: C.ArtifactKind,
|
||||
AssetLocationLocationKind: C.LocationKind,
|
||||
AssetLocationStatus: C.LocationStatus,
|
||||
AssetOperationPhase: C.OperationPhase,
|
||||
AssetOperationKind: C.OperationKind,
|
||||
AssetOperationStatus: C.OperationStatus,
|
||||
AssetUnitCoverageCoverage: C.CoverageKind,
|
||||
AssetUnitCoverageSegment: C.Segment,
|
||||
AutomationGrantStatus: C.GrantStatus,
|
||||
ContentUnitKind: C.ContentUnitKind,
|
||||
ContentUnitContentType: C.ContentType,
|
||||
ContentUnitSegment: C.Segment,
|
||||
ContentUnitStatus: C.ContentUnitStatus,
|
||||
DecisionKind: C.DecisionKind,
|
||||
DecisionStatus: C.DecisionStatus,
|
||||
DecisionSeverity: C.Severity,
|
||||
DownloadTaskOrigin: C.DownloadOrigin,
|
||||
DownloadTaskStatus: C.DownloadStatus,
|
||||
DownloadTaskPauseReason: C.PauseReason,
|
||||
DownloaderInstanceKind: C.DownloaderKind,
|
||||
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
||||
ExternalIdentityEntityType: C.ExternalEntityType,
|
||||
ExternalIdentityStatus: C.IdentityStatus,
|
||||
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
|
||||
ExternalIdentityVerifyStatus: C.IdentityVerifyStatus,
|
||||
FeedSourcePurpose: C.FeedPurpose,
|
||||
FeedSourceKind: C.FeedKind,
|
||||
FeedSourceHealthStatus: C.FeedHealth,
|
||||
IdentityRevisionAction: C.IdentityRevisionAction,
|
||||
IdentityRevisionFromStatus: C.IdentityStatus,
|
||||
IdentityRevisionToStatus: C.IdentityStatus,
|
||||
IdentityRevisionActorType: C.ActorType,
|
||||
LibraryRootMediaKind: C.LibraryMediaKind,
|
||||
LibraryRootManagementMode: C.ManagementMode,
|
||||
LibraryRootHealthStatus: C.InstanceHealth,
|
||||
MediaAssetKind: C.AssetKind,
|
||||
MediaAssetStatus: C.AssetStatus,
|
||||
MediaEntityKind: C.MediaKind,
|
||||
MediaEntityStatus: C.MediaEntityStatus,
|
||||
MediaServerInstanceKind: C.MediaServerKind,
|
||||
MediaServerInstanceHealthStatus: C.InstanceHealth,
|
||||
ObservationStatus: C.ObservationStatus,
|
||||
OrganizationFileResultRole: C.FileRole,
|
||||
OrganizationFileResultMode: C.OrganizationAction,
|
||||
OrganizationFileResultStatus: C.FileResultStatus,
|
||||
OrganizationRecordTrigger: C.OrganizationTrigger,
|
||||
OrganizationRecordActorType: C.ActorType,
|
||||
OrganizationRecordSourcePathView: C.SourcePathView,
|
||||
OrganizationRecordAction: C.OrganizationAction,
|
||||
OrganizationRecordStatus: C.OrganizationStatus,
|
||||
OutboxEventStatus: C.OutboxStatus,
|
||||
PathBindingBindingKind: C.BindingKind,
|
||||
PathBindingStatus: C.BindingStatus,
|
||||
PathMappingViewKind: C.PathViewKind,
|
||||
PathMappingVerifyStatus: C.PathMappingVerifyStatus,
|
||||
PolicyRevisionKind: C.PolicyKind,
|
||||
PolicyRevisionStatus: C.PolicyStatus,
|
||||
PreferenceTagKind: C.PreferenceKind,
|
||||
PreferenceTagOrigin: C.PreferenceOrigin,
|
||||
PreferenceTagStatus: C.PreferenceStatus,
|
||||
ReconcileRunKind: C.ReconcileKind,
|
||||
ReconcileRunMode: C.ReconcileMode,
|
||||
ReconcileRunStatus: C.ReconcileStatus,
|
||||
SecretPurpose: C.SecretPurpose,
|
||||
ServerMappingItemType: C.ServerItemType,
|
||||
ServerMappingMatchStatus: C.ServerMatchStatus,
|
||||
SiteAdapter: C.SiteAdapter,
|
||||
SiteHealthStatus: C.SiteHealth,
|
||||
SiteCircuitState: C.CircuitState,
|
||||
SiteAccountAuthKind: C.SiteAuthKind,
|
||||
SiteAccountStatus: C.SiteAccountStatus,
|
||||
SourceReleaseResolutionStatus: C.ResolutionStatus,
|
||||
SourceReleaseAvailability: C.ReleaseAvailability,
|
||||
SubscriptionMediaKind: C.SubscriptionMediaKind,
|
||||
SubscriptionMode: C.SubscriptionMode,
|
||||
SubscriptionSeasonScope: C.SeasonScope,
|
||||
SubscriptionOrigin: C.SubscriptionOrigin,
|
||||
SubscriptionPriority: C.AcquisitionPriority,
|
||||
SubscriptionStatus: C.SubscriptionStatus,
|
||||
TaskStatus: C.TaskStatus,
|
||||
TitleAliasKind: C.AliasKind,
|
||||
TitleAliasSource: C.AliasSource,
|
||||
};
|
||||
|
||||
describe("contracts ⇔ db 表结构同步", () => {
|
||||
const tables = tableColumns();
|
||||
|
||||
it("每张表都有 Row schema", () => {
|
||||
expect([...tables.keys()].sort()).toEqual(Object.keys(ROWS).sort());
|
||||
});
|
||||
|
||||
for (const [table, spec] of Object.entries(ROWS)) {
|
||||
it(`${table} 列与 Row 字段一致`, () => {
|
||||
const dbCols = (tables.get(table) ?? []).map(camel).filter((c) => !spec.dbOnly?.includes(c));
|
||||
const apiCols = Object.keys(spec.row.shape).filter((c) => !spec.apiOnly?.includes(c));
|
||||
expect(apiCols.sort()).toEqual(dbCols.sort());
|
||||
});
|
||||
}
|
||||
|
||||
it("每个 DB 枚举都有对应 contracts 枚举且取值一致", () => {
|
||||
const aliases = enumAliases();
|
||||
expect([...aliases.keys()].sort()).toEqual(Object.keys(ENUMS).sort());
|
||||
for (const [name, values] of aliases) {
|
||||
expect(ENUMS[name]!.options, name).toEqual(values);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as C from "../src";
|
||||
|
||||
// 对 contracts 导出的每个 zod schema 做正反例 parse:
|
||||
// 正例由 sample() 按 schema 结构生成(字符串/数字按候选值逐个试到通过),个别 refine 由 OVERRIDES 修正;
|
||||
// 反例把一个必填字段替换成非法值(对象)或直接传 Symbol(非对象)。
|
||||
|
||||
interface Def {
|
||||
type: string;
|
||||
innerType?: z.ZodType;
|
||||
shape?: Record<string, z.ZodType>;
|
||||
options?: z.ZodType[];
|
||||
entries?: Record<string, string>;
|
||||
values?: unknown[];
|
||||
element?: z.ZodType;
|
||||
in?: z.ZodType;
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
const def = (s: z.ZodType) => (s as unknown as { _zod: { def: Def } })._zod.def;
|
||||
|
||||
const UUID = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
|
||||
const STRING_CANDIDATES = [
|
||||
UUID,
|
||||
"2026-09-27T08:00:00.000Z",
|
||||
"2026-09-27",
|
||||
"https://example.test/x",
|
||||
"a".repeat(40),
|
||||
"/media/movies",
|
||||
"tmdb",
|
||||
"123",
|
||||
"CatalogLookupInput",
|
||||
"catalog.lookup",
|
||||
"测试 Test",
|
||||
];
|
||||
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
||||
|
||||
function pick<T>(schema: z.ZodType, candidates: readonly T[]): T {
|
||||
const hit = candidates.find((c) => schema.safeParse(c).success);
|
||||
if (hit === undefined) throw new Error(`没有候选值能通过 ${def(schema).type}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
function sample(schema: z.ZodType): unknown {
|
||||
const d = def(schema);
|
||||
switch (d.type) {
|
||||
case "string":
|
||||
return pick(schema, STRING_CANDIDATES);
|
||||
case "number":
|
||||
return pick(schema, NUMBER_CANDIDATES);
|
||||
case "boolean":
|
||||
return true;
|
||||
case "enum":
|
||||
return Object.values(d.entries!)[0];
|
||||
case "literal":
|
||||
return d.values![0];
|
||||
case "nullable":
|
||||
case "optional":
|
||||
case "default":
|
||||
case "prefault":
|
||||
case "readonly":
|
||||
return sample(d.innerType!);
|
||||
case "array":
|
||||
return [sample(d.element!)];
|
||||
case "record":
|
||||
return { k: "v" };
|
||||
case "unknown":
|
||||
return { any: 1 };
|
||||
case "union":
|
||||
return sample(d.options![0]!);
|
||||
case "pipe":
|
||||
return sample(d.in!);
|
||||
case "object": {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(d.shape!)) out[k] = sample(v);
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
throw new Error(`sample 未支持 ${d.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 需要满足跨字段 refine 的正例修正 */
|
||||
const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
PathMappingCreate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
|
||||
PathMappingUpdate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
|
||||
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
|
||||
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
|
||||
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
|
||||
CatalogLookupInput: (v) => v,
|
||||
IdentityResolveInput: (v) => v,
|
||||
AssetLocationCreate: (v) => v,
|
||||
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
|
||||
GlobalSettings: () => C.DEFAULT_SETTINGS,
|
||||
TypedEventEnvelope: () => ({
|
||||
seq: 1,
|
||||
at: "2026-09-27T08:00:00Z",
|
||||
type: "system.heartbeat",
|
||||
payload: { role: "all" },
|
||||
}),
|
||||
};
|
||||
|
||||
const isSchema = (v: unknown): v is z.ZodType => v instanceof z.ZodType;
|
||||
const schemas: [string, z.ZodType][] = [];
|
||||
for (const [k, v] of Object.entries(C) as [string, unknown][]) if (isSchema(v)) schemas.push([k, v]);
|
||||
|
||||
describe("所有导出 schema 的正反例", () => {
|
||||
it("导出了足够多的 schema", () => {
|
||||
expect(schemas.length).toBeGreaterThan(150);
|
||||
});
|
||||
|
||||
for (const [name, schema] of schemas) {
|
||||
it(name, () => {
|
||||
let good = sample(schema);
|
||||
const fix = OVERRIDES[name];
|
||||
if (fix && good && typeof good === "object") good = fix(good as Record<string, unknown>);
|
||||
const ok = schema.safeParse(good);
|
||||
if (!ok.success) throw new Error(`${name} 正例失败: ${ok.error.message}\n${JSON.stringify(good)}`);
|
||||
|
||||
// 反例
|
||||
const d = def(schema);
|
||||
if (d.type === "object") {
|
||||
const shape = d.shape!;
|
||||
const key = Object.keys(shape).find(
|
||||
(k) => !shape[k]!.safeParse(undefined).success && def(shape[k]!).type !== "unknown",
|
||||
);
|
||||
const bad = key ? { ...(good as object), [key]: Symbol("bad") } : Symbol("bad");
|
||||
expect(schema.safeParse(bad).success, `${name} 反例应失败(${key ?? "整体"})`).toBe(false);
|
||||
} else {
|
||||
expect(schema.safeParse(Symbol("bad")).success, `${name} 反例应失败`).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("关键合同行为", () => {
|
||||
it("Slot 状态集合与 REQ §6.1 一致", () => {
|
||||
expect(C.SlotStatus.options).toEqual([
|
||||
"empty",
|
||||
"reserved",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"satisfied",
|
||||
"upgrade_pending",
|
||||
"attention",
|
||||
]);
|
||||
});
|
||||
|
||||
it("CacheSettings 拒绝恢复水位 ≥ 硬阈值", () => {
|
||||
expect(C.CacheSettings.safeParse({ ...C.DEFAULT_SETTINGS.cache, resumePercent: 95 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => {
|
||||
const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
|
||||
expect(patch).toEqual({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
|
||||
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
|
||||
expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false });
|
||||
expect(merged.pi).toEqual(C.DEFAULT_SETTINGS.pi);
|
||||
expect(C.GlobalSettings.safeParse(merged).success).toBe(true);
|
||||
});
|
||||
|
||||
it("OrganizationChangesQuery 接受查询串并给默认值", () => {
|
||||
expect(C.OrganizationChangesQuery.parse({ after: "42", limit: "10", includeFiles: "false" })).toEqual({
|
||||
after: "42",
|
||||
limit: 10,
|
||||
includeFiles: false,
|
||||
});
|
||||
expect(C.OrganizationChangesQuery.safeParse({ after: "-1" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("ExternalIdentityCreate 要求且只能有一个本地目标", () => {
|
||||
const base = { provider: "tmdb", entityType: "movie", externalId: "1" };
|
||||
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID }).success).toBe(true);
|
||||
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID, contentUnitId: UUID }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("能力目录覆盖首批 14 个能力且 schema 引用都存在", () => {
|
||||
expect(Object.keys(C.CAPABILITY_CATALOG).sort()).toEqual([...C.CAPABILITY_IDS].sort());
|
||||
const exported = C as Record<string, unknown>;
|
||||
for (const cap of Object.values(C.CAPABILITY_CATALOG)) {
|
||||
expect(C.CapabilityDescriptor.safeParse(cap).success).toBe(true);
|
||||
expect(isSchema(exported[cap.inputSchema]), cap.inputSchema).toBe(true);
|
||||
expect(isSchema(exported[cap.outputSchema]), cap.outputSchema).toBe(true);
|
||||
expect(exported[cap.inputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].input);
|
||||
}
|
||||
});
|
||||
|
||||
it("事件 payload 表覆盖全部事件类型", () => {
|
||||
expect(Object.keys(C.EVENT_PAYLOADS).sort()).toEqual(Object.values(C.EventType).sort());
|
||||
expect(
|
||||
C.parseEventPayload("task.status_changed", {
|
||||
taskId: UUID,
|
||||
kind: "ingestFeed",
|
||||
from: null,
|
||||
to: "running",
|
||||
error: null,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(C.parseEventPayload("task.status_changed", { taskId: "x" })).toBeNull();
|
||||
expect(C.isEventType("nope.x")).toBe(false);
|
||||
});
|
||||
|
||||
it("API 端点路径唯一且 buildPath 填参", () => {
|
||||
const eps = C.listEndpoints();
|
||||
const keys = eps.map((e) => `${e.method} ${e.path}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(C.buildPath(C.Api.tasks.cancel.path, { id: UUID })).toBe(`/api/v1/tasks/${UUID}/cancel`);
|
||||
expect(() => C.buildPath(C.Api.tasks.get.path)).toThrow();
|
||||
// 公开整理记录读写分权
|
||||
expect(C.Api.organization.changes.scope).toBe("organize.read");
|
||||
expect(C.Api.organization.create.scope).toBe("organize.execute");
|
||||
});
|
||||
});
|
||||
+108
-1
@@ -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。
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 底座:扩展、updated_at 触发器函数、加密凭证表 secret、outbox 投递字段(ADR-002 §2.1)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
create extension if not exists pgcrypto with schema public;
|
||||
create extension if not exists pg_trgm with schema public;
|
||||
|
||||
create or replace function umt_touch_updated_at() returns trigger language plpgsql as $$
|
||||
begin
|
||||
new.updated_at := now();
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
${entityTable(
|
||||
"secret",
|
||||
`
|
||||
name text not null,
|
||||
purpose text not null ${oneOf("purpose", ["site_cookie", "site_passkey", "site_api_key", "site_password", "feed_url", "downloader_password", "media_server_api_key", "metadata_api_key", "llm_api_key", "other"])},
|
||||
ciphertext bytea not null,
|
||||
nonce bytea not null,
|
||||
auth_tag bytea,
|
||||
algorithm text not null default 'aes-256-gcm',
|
||||
kdf text not null default 'hkdf-sha256',
|
||||
kdf_params jsonb not null default '{}'::jsonb,
|
||||
key_version integer not null default 1,
|
||||
rotated_at timestamptz,
|
||||
constraint secret_name_key unique (name)`,
|
||||
)}
|
||||
|
||||
alter table outbox_event
|
||||
add column idempotency_key text,
|
||||
add column status text not null default 'pending' ${oneOf("status", ["pending", "dispatched", "failed", "dead"])},
|
||||
add column attempts integer not null default 0,
|
||||
add column next_attempt_at timestamptz not null default now(),
|
||||
add column last_error jsonb;
|
||||
create unique index outbox_event_idempotency_key_idx on outbox_event (idempotency_key) where idempotency_key is not null;
|
||||
create index outbox_event_dispatch_idx on outbox_event (next_attempt_at) where status = 'pending';
|
||||
|
||||
alter table api_key add column updated_at timestamptz not null default now();
|
||||
create trigger api_key_touch_updated_at before update on api_key
|
||||
for each row execute function umt_touch_updated_at();
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
drop trigger if exists api_key_touch_updated_at on api_key;
|
||||
alter table api_key drop column if exists updated_at;
|
||||
drop index if exists outbox_event_dispatch_idx;
|
||||
drop index if exists outbox_event_idempotency_key_idx;
|
||||
alter table outbox_event
|
||||
drop column if exists idempotency_key,
|
||||
drop column if exists status,
|
||||
drop column if exists attempts,
|
||||
drop column if exists next_attempt_at,
|
||||
drop column if exists last_error;
|
||||
${dropTables(["secret"])}
|
||||
drop function if exists umt_touch_updated_at();
|
||||
-- 扩展为库级共享对象,down 不卸载
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 实例域:媒体库根、下载器实例、媒体服务器实例、多实例路径映射(REQ §7.1、§8.4、§12.5)
|
||||
|
||||
const HEALTH = ["unknown", "healthy", "degraded", "offline"] as const;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"library_root",
|
||||
`
|
||||
name text not null,
|
||||
media_kind text not null default 'mixed' ${oneOf("media_kind", ["movie", "series", "variety", "mixed", "other"])},
|
||||
local_path text not null,
|
||||
management_mode text not null default 'register_only' ${oneOf("management_mode", ["register_only", "managed"])},
|
||||
enabled boolean not null default true,
|
||||
sentinel_file text,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
|
||||
last_health_at timestamptz,
|
||||
health_detail jsonb not null default '{}'::jsonb,
|
||||
notes text,
|
||||
constraint library_root_name_key unique (name),
|
||||
constraint library_root_local_path_key unique (local_path)`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"downloader_instance",
|
||||
`
|
||||
name text not null,
|
||||
kind text not null default 'qbittorrent' ${oneOf("kind", ["qbittorrent"])},
|
||||
base_url text not null,
|
||||
username text,
|
||||
secret_ref uuid references secret (id) on delete restrict,
|
||||
managed_tag text not null default 'umt',
|
||||
cache_volume_key text,
|
||||
enabled boolean not null default true,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
|
||||
last_seen_at timestamptz,
|
||||
version_info jsonb not null default '{}'::jsonb,
|
||||
settings jsonb not null default '{}'::jsonb,
|
||||
constraint downloader_instance_name_key unique (name)`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"media_server_instance",
|
||||
`
|
||||
name text not null,
|
||||
kind text not null default 'emby' ${oneOf("kind", ["emby", "jellyfin"])},
|
||||
base_url text not null,
|
||||
user_id text,
|
||||
secret_ref uuid references secret (id) on delete restrict,
|
||||
enabled boolean not null default true,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
|
||||
last_seen_at timestamptz,
|
||||
version_info jsonb not null default '{}'::jsonb,
|
||||
settings jsonb not null default '{}'::jsonb,
|
||||
constraint media_server_instance_name_key unique (name)`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"path_mapping",
|
||||
`
|
||||
view_kind text not null ${oneOf("view_kind", ["downloader", "media_server"])},
|
||||
downloader_instance_id uuid references downloader_instance (id) on delete cascade,
|
||||
media_server_instance_id uuid references media_server_instance (id) on delete cascade,
|
||||
library_root_id uuid references library_root (id) on delete set null,
|
||||
remote_prefix text not null,
|
||||
local_prefix text not null,
|
||||
priority integer not null default 0,
|
||||
enabled boolean not null default true,
|
||||
verify_status text not null default 'unverified' ${oneOf("verify_status", ["unverified", "ok", "failed"])},
|
||||
verified_at timestamptz,
|
||||
verify_detail jsonb not null default '{}'::jsonb,
|
||||
constraint path_mapping_view_owner_check check (
|
||||
(view_kind = 'downloader' and downloader_instance_id is not null and media_server_instance_id is null)
|
||||
or (view_kind = 'media_server' and media_server_instance_id is not null and downloader_instance_id is null)
|
||||
)`,
|
||||
)}
|
||||
create unique index path_mapping_downloader_prefix_idx on path_mapping (downloader_instance_id, remote_prefix)
|
||||
where downloader_instance_id is not null;
|
||||
create unique index path_mapping_media_server_prefix_idx on path_mapping (media_server_instance_id, remote_prefix)
|
||||
where media_server_instance_id is not null;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["path_mapping", "media_server_instance", "downloader_instance", "library_root"]));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 内容域:本地作品 media_entity、内容单元 content_unit、别名 title_alias(REQ §3.1、§5.1、§5.4、§7.5)
|
||||
// 本地 UUID 为主身份;外部 ID 与集号都不是唯一身份,编号可修订。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"media_entity",
|
||||
`
|
||||
kind text not null ${oneOf("kind", ["movie", "series", "variety", "collection", "other"])},
|
||||
title text not null,
|
||||
original_title text,
|
||||
year integer,
|
||||
status text not null default 'active' ${oneOf("status", ["provisional", "active", "merged", "archived"])},
|
||||
merged_into_id uuid references media_entity (id) on delete restrict,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
field_locks text[] not null default '{}'::text[],
|
||||
last_metadata_refresh_at timestamptz,
|
||||
constraint media_entity_merged_check check ((status = 'merged') = (merged_into_id is not null))`,
|
||||
)}
|
||||
create index media_entity_kind_idx on media_entity (kind);
|
||||
create index media_entity_merged_into_idx on media_entity (merged_into_id) where merged_into_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"content_unit",
|
||||
`
|
||||
media_entity_id uuid not null references media_entity (id) on delete restrict,
|
||||
parent_unit_id uuid references content_unit (id) on delete restrict,
|
||||
kind text not null ${oneOf("kind", ["season", "episode", "special", "part"])},
|
||||
content_type text not null default 'main' ${oneOf("content_type", ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction", "unknown"])},
|
||||
segment text not null default 'full' ${oneOf("segment", ["full", "upper", "middle", "lower", "unknown"])},
|
||||
season_number integer,
|
||||
episode_number integer,
|
||||
source_issue text,
|
||||
air_date date,
|
||||
display_order double precision,
|
||||
title text,
|
||||
status text not null default 'provisional' ${oneOf("status", ["provisional", "confirmed", "retired"])},
|
||||
numbering_locked boolean not null default false,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
field_locks text[] not null default '{}'::text[]`,
|
||||
)}
|
||||
create index content_unit_entity_numbering_idx on content_unit (media_entity_id, season_number, episode_number);
|
||||
create index content_unit_parent_idx on content_unit (parent_unit_id) where parent_unit_id is not null;
|
||||
create index content_unit_air_date_idx on content_unit (media_entity_id, air_date) where air_date is not null;
|
||||
|
||||
${entityTable(
|
||||
"title_alias",
|
||||
`
|
||||
media_entity_id uuid not null references media_entity (id) on delete cascade,
|
||||
content_unit_id uuid references content_unit (id) on delete cascade,
|
||||
alias text not null,
|
||||
normalized text not null,
|
||||
language text,
|
||||
kind text not null default 'other' ${oneOf("kind", ["original", "translated", "short", "site_title", "romanized", "other"])},
|
||||
source text not null ${oneOf("source", ["tmdb", "user", "site", "agent", "nfo", "import"])},
|
||||
locked boolean not null default false`,
|
||||
)}
|
||||
create unique index title_alias_entity_normalized_idx on title_alias (media_entity_id, content_unit_id, normalized, kind) nulls not distinct;
|
||||
create index title_alias_normalized_trgm_idx on title_alias using gin (normalized gin_trgm_ops);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["title_alias", "content_unit", "media_entity"]));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 身份域:external_identity + identity_revision(REQ §7.2)
|
||||
// provider + entity_type + external_id 对 accepted 唯一;候选可并存但不能同时被当作已确认身份。
|
||||
|
||||
const IDENTITY_STATUS = ["accepted", "proposed", "conflicted", "retired"] as const;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"external_identity",
|
||||
`
|
||||
media_entity_id uuid references media_entity (id) on delete restrict,
|
||||
content_unit_id uuid references content_unit (id) on delete restrict,
|
||||
provider text not null check (provider ~ '^[a-z][a-z0-9_]*$'),
|
||||
entity_type text not null ${oneOf("entity_type", ["movie", "tv", "season", "episode", "collection", "person"])},
|
||||
external_id text not null,
|
||||
status text not null default 'proposed' ${oneOf("status", IDENTITY_STATUS)},
|
||||
evidence_level text not null default 'needs_review' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])},
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
locked boolean not null default false,
|
||||
revision integer not null default 1,
|
||||
valid_from timestamptz,
|
||||
valid_until timestamptz,
|
||||
verified_at timestamptz,
|
||||
verify_status text not null default 'unverified' ${oneOf("verify_status", ["unverified", "ok", "not_found", "type_mismatch", "error"])},
|
||||
extra jsonb not null default '{}'::jsonb,
|
||||
constraint external_identity_target_check check (num_nonnulls(media_entity_id, content_unit_id) = 1)`,
|
||||
)}
|
||||
create unique index external_identity_accepted_key on external_identity (provider, entity_type, external_id)
|
||||
where status = 'accepted';
|
||||
create unique index external_identity_entity_accepted_key on external_identity (media_entity_id, provider, entity_type)
|
||||
where status = 'accepted' and media_entity_id is not null;
|
||||
create unique index external_identity_unit_accepted_key on external_identity (content_unit_id, provider, entity_type)
|
||||
where status = 'accepted' and content_unit_id is not null;
|
||||
create index external_identity_lookup_idx on external_identity (provider, entity_type, external_id);
|
||||
create index external_identity_entity_idx on external_identity (media_entity_id) where media_entity_id is not null;
|
||||
create index external_identity_unit_idx on external_identity (content_unit_id) where content_unit_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"identity_revision",
|
||||
`
|
||||
external_identity_id uuid not null references external_identity (id) on delete cascade,
|
||||
revision integer not null,
|
||||
action text not null ${oneOf("action", ["created", "status_changed", "relinked", "evidence_updated", "locked", "unlocked"])},
|
||||
from_status text ${oneOf("from_status", IDENTITY_STATUS)},
|
||||
to_status text ${oneOf("to_status", IDENTITY_STATUS)},
|
||||
before jsonb,
|
||||
after jsonb not null default '{}'::jsonb,
|
||||
actor_type text not null ${oneOf("actor_type", ["user", "agent", "system", "api_key"])},
|
||||
actor_id text,
|
||||
reason text,
|
||||
constraint identity_revision_seq_key unique (external_identity_id, revision)`,
|
||||
)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["identity_revision", "external_identity"]));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 来源域:site、site_account、feed_source、source_release、observation(REQ §4.1–§4.6)
|
||||
// 站点级限流共享;多 Feed 观察关联同一 source_release;含 passkey 的 URL 只以 secret_ref 保存。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"site",
|
||||
`
|
||||
key text not null check (key ~ '^[a-z][a-z0-9_-]*$'),
|
||||
name text not null,
|
||||
adapter text not null ${oneOf("adapter", ["nexusphp", "mteam_api", "generic_rss"])},
|
||||
base_url text not null,
|
||||
enabled boolean not null default true,
|
||||
rate_limit jsonb not null default '{}'::jsonb,
|
||||
config jsonb not null default '{}'::jsonb,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", ["unknown", "healthy", "degraded", "failing", "disabled"])},
|
||||
circuit_state text not null default 'closed' ${oneOf("circuit_state", ["closed", "open", "half_open"])},
|
||||
circuit_open_until timestamptz,
|
||||
snapshot_enabled boolean not null default false,
|
||||
snapshot_interval_sec integer,
|
||||
last_snapshot_at timestamptz,
|
||||
snapshot_cursor jsonb not null default '{}'::jsonb,
|
||||
constraint site_key_key unique (key)`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"site_account",
|
||||
`
|
||||
site_id uuid not null references site (id) on delete cascade,
|
||||
label text not null,
|
||||
auth_kind text not null ${oneOf("auth_kind", ["cookie", "passkey", "api_key", "password"])},
|
||||
secret_ref uuid references secret (id) on delete restrict,
|
||||
enabled boolean not null default true,
|
||||
status text not null default 'unknown' ${oneOf("status", ["unknown", "active", "invalid", "expired"])},
|
||||
last_verified_at timestamptz,
|
||||
user_info jsonb not null default '{}'::jsonb,
|
||||
constraint site_account_label_key unique (site_id, label)`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"feed_source",
|
||||
`
|
||||
site_id uuid references site (id) on delete cascade,
|
||||
site_account_id uuid references site_account (id) on delete set null,
|
||||
name text not null,
|
||||
purpose text not null default 'generic' ${oneOf("purpose", ["movie", "series", "variety", "promotion", "search", "generic"])},
|
||||
kind text not null default 'rss' ${oneOf("kind", ["rss", "atom", "list", "query"])},
|
||||
url_secret_ref uuid references secret (id) on delete restrict,
|
||||
url_display text not null,
|
||||
parser_version text not null default '1',
|
||||
enabled boolean not null default true,
|
||||
poll_interval_sec integer not null default 900 check (poll_interval_sec >= 60),
|
||||
filter jsonb not null default '{}'::jsonb,
|
||||
cursor jsonb not null default '{}'::jsonb,
|
||||
etag text,
|
||||
last_modified text,
|
||||
last_fetch_at timestamptz,
|
||||
last_success_at timestamptz,
|
||||
next_fetch_at timestamptz,
|
||||
consecutive_failures integer not null default 0,
|
||||
backoff_until timestamptz,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", ["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"])},
|
||||
last_error jsonb`,
|
||||
)}
|
||||
create index feed_source_due_idx on feed_source (next_fetch_at) where enabled;
|
||||
create index feed_source_site_idx on feed_source (site_id);
|
||||
|
||||
${entityTable(
|
||||
"source_release",
|
||||
`
|
||||
site_id uuid not null references site (id) on delete restrict,
|
||||
site_release_id text not null,
|
||||
title text not null,
|
||||
subtitle text,
|
||||
category_raw text,
|
||||
tags_raw text[] not null default '{}'::text[],
|
||||
size_bytes bigint,
|
||||
infohash_v1 text,
|
||||
infohash_v2 text,
|
||||
file_list jsonb,
|
||||
promotion jsonb not null default '{}'::jsonb,
|
||||
promotion_expires_at timestamptz,
|
||||
seeders integer,
|
||||
leechers integer,
|
||||
source_published_at timestamptz,
|
||||
first_seen_at timestamptz not null default now(),
|
||||
last_seen_at timestamptz not null default now(),
|
||||
detail_fetched_at timestamptz,
|
||||
parsed jsonb not null default '{}'::jsonb,
|
||||
parser_version text,
|
||||
media_entity_id uuid references media_entity (id) on delete set null,
|
||||
content_unit_id uuid references content_unit (id) on delete set null,
|
||||
resolution_status text not null default 'unresolved' ${oneOf("resolution_status", ["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"])},
|
||||
resolution_evidence jsonb not null default '{}'::jsonb,
|
||||
availability text not null default 'active' ${oneOf("availability", ["active", "site_disabled", "expired", "unknown"])},
|
||||
raw jsonb not null default '{}'::jsonb,
|
||||
constraint source_release_site_release_key unique (site_id, site_release_id)`,
|
||||
)}
|
||||
create index source_release_infohash_v1_idx on source_release (infohash_v1) where infohash_v1 is not null;
|
||||
create index source_release_infohash_v2_idx on source_release (infohash_v2) where infohash_v2 is not null;
|
||||
create index source_release_entity_idx on source_release (media_entity_id) where media_entity_id is not null;
|
||||
create index source_release_unresolved_idx on source_release (first_seen_at) where resolution_status in ('unresolved', 'ambiguous');
|
||||
create index source_release_title_trgm_idx on source_release using gin (title gin_trgm_ops);
|
||||
|
||||
${entityTable(
|
||||
"observation",
|
||||
`
|
||||
feed_id uuid not null references feed_source (id) on delete cascade,
|
||||
guid text not null,
|
||||
content_digest text not null,
|
||||
source_release_id uuid references source_release (id) on delete set null,
|
||||
title text not null,
|
||||
link_redacted text,
|
||||
raw jsonb not null default '{}'::jsonb,
|
||||
source_published_at timestamptz,
|
||||
fetched_at timestamptz not null default now(),
|
||||
first_seen_at timestamptz not null default now(),
|
||||
last_seen_at timestamptz not null default now(),
|
||||
status text not null default 'new' ${oneOf("status", ["new", "linked", "ignored", "error"])},
|
||||
resolve_attempts integer not null default 0,
|
||||
next_resolve_at timestamptz,
|
||||
last_error jsonb,
|
||||
constraint observation_feed_guid_key unique (feed_id, guid)`,
|
||||
)}
|
||||
create index observation_release_idx on observation (source_release_id) where source_release_id is not null;
|
||||
create index observation_next_resolve_idx on observation (next_resolve_at) where status in ('new', 'error');
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["observation", "source_release", "feed_source", "site_account", "site"]));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 文件域:media_asset、asset_unit_coverage、asset_location、path_binding(REQ §7.1、§8.2、§12.2)
|
||||
// 资产与位置分离;同一库根 + 有效相对路径不能被两个位置同时占用;
|
||||
// path_binding 一旦建立不因元数据刷新改变,只有显式整理操作可替换(旧绑定标 superseded)。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"media_asset",
|
||||
`
|
||||
media_entity_id uuid references media_entity (id) on delete restrict,
|
||||
kind text not null ${oneOf("kind", ["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"])},
|
||||
parent_asset_id uuid references media_asset (id) on delete restrict,
|
||||
size_bytes bigint,
|
||||
content_hash text,
|
||||
hash_algorithm text,
|
||||
sample_digest text,
|
||||
media_info jsonb not null default '{}'::jsonb,
|
||||
delivery jsonb not null default '{}'::jsonb,
|
||||
content_edition text,
|
||||
source_release_id uuid references source_release (id) on delete set null,
|
||||
status text not null default 'active' ${oneOf("status", ["active", "replaced", "retired", "missing"])},
|
||||
verified_at timestamptz`,
|
||||
)}
|
||||
create index media_asset_entity_idx on media_asset (media_entity_id) where media_entity_id is not null;
|
||||
create index media_asset_parent_idx on media_asset (parent_asset_id) where parent_asset_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"asset_unit_coverage",
|
||||
`
|
||||
media_asset_id uuid not null references media_asset (id) on delete cascade,
|
||||
content_unit_id uuid not null references content_unit (id) on delete restrict,
|
||||
coverage text not null default 'full' ${oneOf("coverage", ["full", "partial", "segment", "unknown"])},
|
||||
segment text ${oneOf("segment", ["full", "upper", "middle", "lower", "unknown"])},
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
constraint asset_unit_coverage_key unique (media_asset_id, content_unit_id)`,
|
||||
)}
|
||||
create index asset_unit_coverage_unit_idx on asset_unit_coverage (content_unit_id);
|
||||
|
||||
${entityTable(
|
||||
"asset_location",
|
||||
`
|
||||
media_asset_id uuid not null references media_asset (id) on delete cascade,
|
||||
location_kind text not null default 'library' ${oneOf("location_kind", ["library", "download_cache", "hardlink", "external"])},
|
||||
library_root_id uuid references library_root (id) on delete restrict,
|
||||
downloader_instance_id uuid references downloader_instance (id) on delete set null,
|
||||
relative_path text not null,
|
||||
status text not null default 'available' ${oneOf("status", ["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"])},
|
||||
is_primary boolean not null default false,
|
||||
size_bytes bigint,
|
||||
inode bigint,
|
||||
device bigint,
|
||||
mtime timestamptz,
|
||||
sidecar_of_location_id uuid references asset_location (id) on delete set null,
|
||||
last_seen_at timestamptz,
|
||||
last_checked_at timestamptz,
|
||||
check_detail jsonb not null default '{}'::jsonb,
|
||||
constraint asset_location_library_root_check check (location_kind <> 'library' or library_root_id is not null)`,
|
||||
)}
|
||||
create unique index asset_location_root_path_key on asset_location (library_root_id, relative_path)
|
||||
where status <> 'removed' and library_root_id is not null;
|
||||
create index asset_location_asset_idx on asset_location (media_asset_id);
|
||||
create index asset_location_check_idx on asset_location (last_checked_at) where status <> 'removed';
|
||||
|
||||
${entityTable(
|
||||
"path_binding",
|
||||
`
|
||||
media_entity_id uuid not null references media_entity (id) on delete restrict,
|
||||
content_unit_id uuid references content_unit (id) on delete restrict,
|
||||
season_number integer,
|
||||
binding_kind text not null ${oneOf("binding_kind", ["entity", "season"])},
|
||||
library_root_id uuid not null references library_root (id) on delete restrict,
|
||||
relative_path text not null,
|
||||
template_version text,
|
||||
status text not null default 'active' ${oneOf("status", ["active", "conflict", "superseded"])},
|
||||
bound_at timestamptz not null default now(),
|
||||
bound_by text,
|
||||
superseded_by_id uuid references path_binding (id) on delete set null,
|
||||
note text,
|
||||
constraint path_binding_kind_check check ((binding_kind = 'season') = (season_number is not null))`,
|
||||
)}
|
||||
create index path_binding_entity_season_idx on path_binding (media_entity_id, season_number);
|
||||
create unique index path_binding_active_key on path_binding (media_entity_id, season_number) nulls not distinct
|
||||
where status = 'active';
|
||||
create unique index path_binding_active_path_key on path_binding (library_root_id, relative_path)
|
||||
where status = 'active';
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["path_binding", "asset_location", "asset_unit_coverage", "media_asset"]));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 规则域:subscription、policy_revision、acquisition_slot、preference_tag、preference_suppression(REQ §6)
|
||||
// 每个 Slot 独立状态;策略版本化(草案/发布/回滚);取消的偏好留抑制记录,Agent 不得据旧证据自动加回。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"policy_revision",
|
||||
`
|
||||
policy_key text not null,
|
||||
revision integer not null check (revision >= 1),
|
||||
kind text not null ${oneOf("kind", ["acquisition", "preference", "retention", "cleanup", "instance", "automation"])},
|
||||
status text not null default 'draft' ${oneOf("status", ["draft", "published", "superseded", "rolled_back", "archived"])},
|
||||
title text not null,
|
||||
summary text,
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
rules jsonb not null default '{}'::jsonb,
|
||||
preview jsonb,
|
||||
based_on_revision_id uuid references policy_revision (id) on delete set null,
|
||||
created_by text,
|
||||
published_at timestamptz,
|
||||
published_by text,
|
||||
constraint policy_revision_key unique (policy_key, revision)`,
|
||||
)}
|
||||
create unique index policy_revision_published_key on policy_revision (policy_key) where status = 'published';
|
||||
|
||||
${entityTable(
|
||||
"subscription",
|
||||
`
|
||||
media_entity_id uuid references media_entity (id) on delete restrict,
|
||||
season_unit_id uuid references content_unit (id) on delete restrict,
|
||||
title_query text,
|
||||
media_kind text not null ${oneOf("media_kind", ["movie", "series", "variety"])},
|
||||
mode text not null default 'continuous' ${oneOf("mode", ["once", "continuous"])},
|
||||
season_scope text not null default 'following_seasons' ${oneOf("season_scope", ["current_season", "following_seasons"])},
|
||||
origin text not null default 'user' ${oneOf("origin", ["user", "agent", "policy", "import"])},
|
||||
priority text not null default 'tracking' ${oneOf("priority", ["immediate", "tracking", "new_work", "upgrade"])},
|
||||
status text not null default 'active' ${oneOf("status", ["active", "paused", "completed", "cancelled"])},
|
||||
policy_revision_id uuid references policy_revision (id) on delete set null,
|
||||
params jsonb not null default '{}'::jsonb,
|
||||
status_reason text,
|
||||
last_checked_at timestamptz,
|
||||
created_by text,
|
||||
constraint subscription_target_check check (media_entity_id is not null or title_query is not null)`,
|
||||
)}
|
||||
create index subscription_status_idx on subscription (status);
|
||||
create index subscription_entity_idx on subscription (media_entity_id) where media_entity_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"acquisition_slot",
|
||||
`
|
||||
media_entity_id uuid not null references media_entity (id) on delete restrict,
|
||||
content_unit_id uuid references content_unit (id) on delete restrict,
|
||||
subscription_id uuid references subscription (id) on delete set null,
|
||||
policy_revision_id uuid references policy_revision (id) on delete set null,
|
||||
name text not null,
|
||||
status text not null default 'empty' ${oneOf("status", ["empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending", "attention"])},
|
||||
hard_conditions jsonb not null default '{}'::jsonb,
|
||||
soft_preferences jsonb not null default '{}'::jsonb,
|
||||
fallback jsonb not null default '{}'::jsonb,
|
||||
upgrade_threshold jsonb not null default '{}'::jsonb,
|
||||
completion jsonb not null default '{}'::jsonb,
|
||||
distinct_group text,
|
||||
require_distinct_asset boolean not null default false,
|
||||
current_asset_id uuid references media_asset (id) on delete set null,
|
||||
status_reason text,
|
||||
last_evaluated_at timestamptz,
|
||||
next_evaluate_at timestamptz,
|
||||
revision integer not null default 1`,
|
||||
)}
|
||||
create unique index acquisition_slot_target_key on acquisition_slot (media_entity_id, content_unit_id, name) nulls not distinct;
|
||||
create index acquisition_slot_status_idx on acquisition_slot (status);
|
||||
create index acquisition_slot_next_evaluate_idx on acquisition_slot (next_evaluate_at) where next_evaluate_at is not null;
|
||||
create index acquisition_slot_subscription_idx on acquisition_slot (subscription_id) where subscription_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"preference_tag",
|
||||
`
|
||||
label text not null,
|
||||
kind text not null ${oneOf("kind", ["soft", "hard", "version", "target"])},
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
rule jsonb not null default '{}'::jsonb,
|
||||
fingerprint text not null,
|
||||
origin text not null default 'manual' ${oneOf("origin", ["manual", "conversation", "agent_inferred"])},
|
||||
description text,
|
||||
status text not null default 'active' ${oneOf("status", ["active", "disabled", "deleted"])},
|
||||
revision integer not null default 1,
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
policy_revision_id uuid references policy_revision (id) on delete set null`,
|
||||
)}
|
||||
create index preference_tag_status_idx on preference_tag (status);
|
||||
create unique index preference_tag_fingerprint_live_key on preference_tag (fingerprint) where status <> 'deleted';
|
||||
|
||||
${entityTable(
|
||||
"preference_suppression",
|
||||
`
|
||||
preference_tag_id uuid references preference_tag (id) on delete set null,
|
||||
fingerprint text not null,
|
||||
reason text,
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
suppressed_by text not null,
|
||||
active boolean not null default true,
|
||||
lifted_at timestamptz,
|
||||
lifted_by text,
|
||||
constraint preference_suppression_lift_check check (active or lifted_at is not null)`,
|
||||
)}
|
||||
create index preference_suppression_active_idx on preference_suppression (fingerprint) where active;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
dropTables(["preference_suppression", "preference_tag", "acquisition_slot", "subscription", "policy_revision"]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 执行域:task(业务工作单/时间线投影)、acquisition_plan、download_task、asset_operation(REQ §8.4、§11)
|
||||
// 引擎(OpenWorkflow)只管过程推进,业务状态以本域表为准(ADR-002)。
|
||||
// 一个 Slot 同时只允许一个有效预留;download_task 以 (instance_id, torrent_hash) 唯一;
|
||||
// asset_operation 是副作用账本,operation_id 由服务端按语义生成且唯一。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"task",
|
||||
`
|
||||
kind text not null check (kind ~ '^[a-z][A-Za-z0-9]*$'),
|
||||
handler_version integer not null default 1,
|
||||
idempotency_key text not null,
|
||||
status text not null default 'pending' ${oneOf("status", ["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"])},
|
||||
priority integer not null default 100,
|
||||
input jsonb not null default '{}'::jsonb,
|
||||
output jsonb,
|
||||
subject_type text,
|
||||
subject_id uuid,
|
||||
parent_task_id uuid references task (id) on delete set null,
|
||||
engine_run_id text,
|
||||
attempts integer not null default 0,
|
||||
max_attempts integer not null default 5,
|
||||
next_run_at timestamptz not null default now(),
|
||||
wait_reason text,
|
||||
cancel_requested_at timestamptz,
|
||||
cancel_reason text,
|
||||
last_error jsonb,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
triggered_by text,
|
||||
constraint task_idempotency_key unique (kind, idempotency_key)`,
|
||||
)}
|
||||
create index task_due_idx on task (status, next_run_at) where status in ('pending', 'waiting');
|
||||
create index task_subject_idx on task (subject_type, subject_id) where subject_id is not null;
|
||||
create index task_parent_idx on task (parent_task_id) where parent_task_id is not null;
|
||||
create index task_created_idx on task (created_at desc);
|
||||
|
||||
${entityTable(
|
||||
"acquisition_plan",
|
||||
`
|
||||
slot_id uuid not null references acquisition_slot (id) on delete restrict,
|
||||
source_release_id uuid not null references source_release (id) on delete restrict,
|
||||
task_id uuid references task (id) on delete set null,
|
||||
purpose text not null default 'fill' ${oneOf("purpose", ["fill", "upgrade", "replace_source"])},
|
||||
status text not null default 'draft' ${oneOf("status", ["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"])},
|
||||
priority text not null default 'tracking' ${oneOf("priority", ["immediate", "tracking", "new_work", "upgrade"])},
|
||||
evaluation jsonb not null default '{}'::jsonb,
|
||||
reserved_bytes bigint not null default 0,
|
||||
budget_reservation jsonb not null default '{}'::jsonb,
|
||||
payload_hash text not null,
|
||||
expected_revision integer not null default 1,
|
||||
idempotency_key text not null,
|
||||
expires_at timestamptz,
|
||||
status_reason text,
|
||||
constraint acquisition_plan_idempotency_key unique (idempotency_key)`,
|
||||
)}
|
||||
create unique index acquisition_plan_slot_active_key on acquisition_plan (slot_id) where status in ('reserved', 'submitted');
|
||||
create index acquisition_plan_status_idx on acquisition_plan (status);
|
||||
create index acquisition_plan_release_idx on acquisition_plan (source_release_id);
|
||||
|
||||
${entityTable(
|
||||
"download_task",
|
||||
`
|
||||
instance_id uuid not null references downloader_instance (id) on delete restrict,
|
||||
torrent_hash text not null check (torrent_hash ~ '^[0-9a-f]{40}$|^[0-9a-f]{64}$'),
|
||||
infohash_v1 text,
|
||||
infohash_v2 text,
|
||||
acquisition_plan_id uuid references acquisition_plan (id) on delete set null,
|
||||
source_release_id uuid references source_release (id) on delete set null,
|
||||
name text not null,
|
||||
save_path text,
|
||||
content_path text,
|
||||
category text,
|
||||
tags text[] not null default '{}'::text[],
|
||||
managed boolean not null default true,
|
||||
origin text not null default 'submitted' ${oneOf("origin", ["submitted", "adopted"])},
|
||||
status text not null default 'queued' ${oneOf("status", ["queued", "submitting", "downloading", "paused", "stalled", "completed", "importing", "imported", "failed", "removed", "missing"])},
|
||||
pause_reason text ${oneOf("pause_reason", ["user", "threshold", "policy", "agent"])},
|
||||
raw_state text,
|
||||
progress double precision not null default 0 check (progress >= 0 and progress <= 1),
|
||||
size_bytes bigint,
|
||||
downloaded_bytes bigint not null default 0,
|
||||
remaining_bytes bigint,
|
||||
ratio double precision,
|
||||
seeding_time_sec bigint,
|
||||
completed_at timestamptz,
|
||||
last_progress_at timestamptz,
|
||||
no_progress_since timestamptz,
|
||||
last_synced_at timestamptz,
|
||||
next_check_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
file_list jsonb,
|
||||
last_error jsonb,
|
||||
constraint download_task_instance_hash_key unique (instance_id, torrent_hash)`,
|
||||
)}
|
||||
create index download_task_status_idx on download_task (status);
|
||||
create index download_task_next_check_idx on download_task (next_check_at) where next_check_at is not null;
|
||||
create index download_task_plan_idx on download_task (acquisition_plan_id) where acquisition_plan_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"asset_operation",
|
||||
`
|
||||
operation_id text not null,
|
||||
task_id uuid references task (id) on delete set null,
|
||||
plan_fingerprint text,
|
||||
ordinal integer not null default 0,
|
||||
phase text not null ${oneOf("phase", ["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"])},
|
||||
kind text not null ${oneOf("kind", ["copy", "move", "rename", "mkdir", "hardlink", "delete_source", "delete_target", "verify", "register", "qb_pause", "qb_resume", "qb_remove", "qb_set_tags", "other"])},
|
||||
status text not null default 'planned' ${oneOf("status", ["planned", "started", "succeeded", "failed", "skipped", "compensated"])},
|
||||
intent jsonb not null default '{}'::jsonb,
|
||||
attempt_token uuid,
|
||||
attempts integer not null default 0,
|
||||
result jsonb,
|
||||
error jsonb,
|
||||
media_asset_id uuid references media_asset (id) on delete set null,
|
||||
download_task_id uuid references download_task (id) on delete set null,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
constraint asset_operation_operation_id_key unique (operation_id)`,
|
||||
)}
|
||||
create index asset_operation_task_idx on asset_operation (task_id, ordinal) where task_id is not null;
|
||||
create index asset_operation_unfinished_idx on asset_operation (updated_at) where status in ('planned', 'started');
|
||||
|
||||
alter table media_asset add column download_task_id uuid references download_task (id) on delete set null;
|
||||
alter table acquisition_slot add column reserved_plan_id uuid references acquisition_plan (id) on delete set null;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
alter table acquisition_slot drop column if exists reserved_plan_id;
|
||||
alter table media_asset drop column if exists download_task_id;
|
||||
${dropTables(["asset_operation", "download_task", "acquisition_plan", "task"])}
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 人工/AI 域:decision(待处理中心)、approval、automation_grant、agent_run、artifact(REQ §10、§11.3、§11.4)
|
||||
// 提案不直接晋升事实;审批绑定 plan ID + payload hash + expected revision;授权先落库再唤醒任务。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"agent_run",
|
||||
`
|
||||
task_id uuid references task (id) on delete set null,
|
||||
parent_run_id uuid references agent_run (id) on delete set null,
|
||||
trigger text not null ${oneOf("trigger", ["user", "event", "schedule", "subagent"])},
|
||||
goal text not null,
|
||||
status text not null default 'queued' ${oneOf("status", ["queued", "running", "waiting", "succeeded", "failed", "cancelled", "budget_exceeded"])},
|
||||
provider text,
|
||||
model text,
|
||||
thinking_level text,
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
budget jsonb not null default '{}'::jsonb,
|
||||
usage jsonb not null default '{}'::jsonb,
|
||||
evidence_version text,
|
||||
session_state jsonb,
|
||||
summary text,
|
||||
tool_call_count integer not null default 0,
|
||||
last_event_seq bigint not null default 0,
|
||||
error jsonb,
|
||||
cancel_requested_at timestamptz,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
created_by text`,
|
||||
)}
|
||||
create index agent_run_status_idx on agent_run (status);
|
||||
create index agent_run_task_idx on agent_run (task_id) where task_id is not null;
|
||||
create index agent_run_created_idx on agent_run (created_at desc);
|
||||
|
||||
${entityTable(
|
||||
"artifact",
|
||||
`
|
||||
kind text not null ${oneOf("kind", ["evidence", "plan", "proposal", "report", "snapshot", "log"])},
|
||||
agent_run_id uuid references agent_run (id) on delete set null,
|
||||
task_id uuid references task (id) on delete set null,
|
||||
subject_type text,
|
||||
subject_id uuid,
|
||||
title text not null,
|
||||
mime_type text not null default 'application/json',
|
||||
content jsonb not null default '{}'::jsonb,
|
||||
content_hash text not null,
|
||||
size_bytes bigint,
|
||||
expires_at timestamptz`,
|
||||
)}
|
||||
create index artifact_run_idx on artifact (agent_run_id) where agent_run_id is not null;
|
||||
create index artifact_subject_idx on artifact (subject_type, subject_id) where subject_id is not null;
|
||||
|
||||
${entityTable(
|
||||
"decision",
|
||||
`
|
||||
kind text not null ${oneOf("kind", ["identity_ambiguous", "download_stalled", "download_failed", "path_conflict", "space_insufficient", "approval_required", "metadata_conflict", "source_unreachable", "other"])},
|
||||
status text not null default 'open' ${oneOf("status", ["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"])},
|
||||
severity text not null default 'warning' ${oneOf("severity", ["info", "warning", "critical"])},
|
||||
title text not null,
|
||||
summary text,
|
||||
subject_type text,
|
||||
subject_id uuid,
|
||||
dedupe_key text not null,
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
options jsonb not null default '[]'::jsonb,
|
||||
resolution jsonb,
|
||||
task_id uuid references task (id) on delete set null,
|
||||
agent_run_id uuid references agent_run (id) on delete set null,
|
||||
attempts integer not null default 0,
|
||||
next_review_at timestamptz,
|
||||
resolved_by text,
|
||||
resolved_at timestamptz`,
|
||||
)}
|
||||
create unique index decision_dedupe_open_key on decision (dedupe_key) where status in ('open', 'investigating', 'awaiting_user');
|
||||
create index decision_status_idx on decision (status, created_at desc);
|
||||
create index decision_next_review_idx on decision (next_review_at) where next_review_at is not null;
|
||||
|
||||
${entityTable(
|
||||
"approval",
|
||||
`
|
||||
plan_type text not null,
|
||||
plan_id uuid not null,
|
||||
payload_hash text not null,
|
||||
expected_revision integer not null,
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'pending' ${oneOf("status", ["pending", "approved", "rejected", "revoked", "expired", "consumed"])},
|
||||
decision_id uuid references decision (id) on delete set null,
|
||||
requested_by text not null,
|
||||
decided_by text,
|
||||
decided_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
reason text`,
|
||||
)}
|
||||
create index approval_plan_idx on approval (plan_type, plan_id);
|
||||
create index approval_pending_idx on approval (created_at) where status = 'pending';
|
||||
|
||||
${entityTable(
|
||||
"automation_grant",
|
||||
`
|
||||
action text not null,
|
||||
policy_revision_id uuid references policy_revision (id) on delete set null,
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
budget jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'active' ${oneOf("status", ["active", "revoked", "expired"])},
|
||||
granted_by text not null,
|
||||
reason text,
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
revoked_by text`,
|
||||
)}
|
||||
create index automation_grant_active_idx on automation_grant (action) where status = 'active';
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["automation_grant", "approval", "decision", "artifact", "agent_run"]));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 运维域:reconcile_run(五类校验器的可恢复运行记录)、server_mapping(Emby 兼容库存映射,REQ §7.4、§8.1)
|
||||
// 外部库存"未知 ≠ 缺失";server_mapping 只是辅助证据,不构成文件纳管授权。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"reconcile_run",
|
||||
`
|
||||
kind text not null ${oneOf("kind", ["identity", "metadata", "asset", "acquisition", "server"])},
|
||||
mode text not null default 'normal' ${oneOf("mode", ["normal", "read_only"])},
|
||||
status text not null default 'queued' ${oneOf("status", ["queued", "running", "succeeded", "partial", "failed", "cancelled"])},
|
||||
scope jsonb not null default '{}'::jsonb,
|
||||
resume_cursor jsonb not null default '{}'::jsonb,
|
||||
stats jsonb not null default '{}'::jsonb,
|
||||
task_id uuid references task (id) on delete set null,
|
||||
error jsonb,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz`,
|
||||
)}
|
||||
create index reconcile_run_kind_idx on reconcile_run (kind, created_at desc);
|
||||
|
||||
${entityTable(
|
||||
"server_mapping",
|
||||
`
|
||||
media_server_instance_id uuid not null references media_server_instance (id) on delete cascade,
|
||||
server_item_id text not null,
|
||||
item_type text not null ${oneOf("item_type", ["movie", "series", "season", "episode", "other"])},
|
||||
media_entity_id uuid references media_entity (id) on delete set null,
|
||||
content_unit_id uuid references content_unit (id) on delete set null,
|
||||
media_asset_id uuid references media_asset (id) on delete set null,
|
||||
server_path text,
|
||||
path_mapping_id uuid references path_mapping (id) on delete set null,
|
||||
match_status text not null default 'unmatched' ${oneOf("match_status", ["matched", "ambiguous", "unmatched", "stale"])},
|
||||
version_info jsonb not null default '{}'::jsonb,
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
observed_at timestamptz not null default now(),
|
||||
constraint server_mapping_item_key unique (media_server_instance_id, server_item_id)`,
|
||||
)}
|
||||
create index server_mapping_entity_idx on server_mapping (media_entity_id) where media_entity_id is not null;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["server_mapping", "reconcile_run"]));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 整理域:organization_record(批次)+ organization_file_result(逐文件)(REQ §12.4)
|
||||
// change_seq:任何 update(含其文件明细变化)都会刷新为新的序列值,用于稳定游标增量读取;
|
||||
// change_xid:最后一次变更的事务号。读游标时只返回 change_xid < pg_snapshot_xmin(pg_current_snapshot()) 的行,
|
||||
// 避免"序号小但提交晚"的事务被游标跳过。
|
||||
|
||||
const RECORD_STATUS = ["queued", "running", "succeeded", "partial", "failed", "cancelled"] as const;
|
||||
const ACTIONS = ["copy", "move", "rename", "relocate", "hardlink"] as const;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
create sequence organization_record_change_seq as bigint;
|
||||
|
||||
${entityTable(
|
||||
"organization_record",
|
||||
`
|
||||
task_id uuid references task (id) on delete set null,
|
||||
trigger text not null ${oneOf("trigger", ["auto", "manual", "agent"])},
|
||||
actor_type text not null ${oneOf("actor_type", ["user", "agent", "system", "api_key"])},
|
||||
actor_id text,
|
||||
media_entity_id uuid references media_entity (id) on delete set null,
|
||||
content_unit_id uuid references content_unit (id) on delete set null,
|
||||
download_task_id uuid references download_task (id) on delete set null,
|
||||
source_downloader_instance_id uuid references downloader_instance (id) on delete set null,
|
||||
source_path_view text not null default 'local' ${oneOf("source_path_view", ["downloader", "local", "library"])},
|
||||
source_path text,
|
||||
target_library_root_id uuid references library_root (id) on delete set null,
|
||||
target_path text,
|
||||
action text not null ${oneOf("action", ACTIONS)},
|
||||
status text not null default 'queued' ${oneOf("status", RECORD_STATUS)},
|
||||
reason text,
|
||||
error jsonb,
|
||||
plan jsonb not null default '{}'::jsonb,
|
||||
plan_fingerprint text,
|
||||
file_total integer not null default 0,
|
||||
file_succeeded integer not null default 0,
|
||||
file_failed integer not null default 0,
|
||||
file_skipped integer not null default 0,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
change_seq bigint not null default nextval('organization_record_change_seq'),
|
||||
change_xid xid8 not null default pg_current_xact_id()`,
|
||||
)}
|
||||
alter sequence organization_record_change_seq owned by organization_record.change_seq;
|
||||
create unique index organization_record_change_seq_key on organization_record (change_seq);
|
||||
create index organization_record_entity_idx on organization_record (media_entity_id) where media_entity_id is not null;
|
||||
create index organization_record_task_idx on organization_record (task_id) where task_id is not null;
|
||||
create index organization_record_status_idx on organization_record (status, created_at desc);
|
||||
|
||||
create function organization_record_bump_change_seq() returns trigger language plpgsql as $$
|
||||
begin
|
||||
new.change_seq := nextval('organization_record_change_seq');
|
||||
new.change_xid := pg_current_xact_id();
|
||||
return new;
|
||||
end $$;
|
||||
create trigger organization_record_bump_change_seq before update on organization_record
|
||||
for each row execute function organization_record_bump_change_seq();
|
||||
|
||||
${entityTable(
|
||||
"organization_file_result",
|
||||
`
|
||||
organization_record_id uuid not null references organization_record (id) on delete cascade,
|
||||
ordinal integer not null default 0,
|
||||
asset_operation_id uuid references asset_operation (id) on delete set null,
|
||||
media_asset_id uuid references media_asset (id) on delete set null,
|
||||
content_unit_id uuid references content_unit (id) on delete set null,
|
||||
role text not null default 'main' ${oneOf("role", ["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"])},
|
||||
mode text not null ${oneOf("mode", ACTIONS)},
|
||||
source_path text not null,
|
||||
source_size_bytes bigint,
|
||||
target_library_root_id uuid references library_root (id) on delete set null,
|
||||
target_path text,
|
||||
status text not null default 'queued' ${oneOf("status", ["queued", "running", "succeeded", "failed", "skipped", "cancelled"])},
|
||||
skip_reason text,
|
||||
error jsonb,
|
||||
checksum text,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz`,
|
||||
)}
|
||||
create index organization_file_result_record_idx on organization_file_result (organization_record_id, ordinal);
|
||||
|
||||
-- 文件明细变化时刷新父记录 change_seq,使增量读取能拿到"既有记录状态更新"
|
||||
create function organization_file_result_touch_record() returns trigger language plpgsql as $$
|
||||
begin
|
||||
update organization_record set updated_at = now()
|
||||
where id = coalesce(new.organization_record_id, old.organization_record_id);
|
||||
return null;
|
||||
end $$;
|
||||
create trigger organization_file_result_touch_record after insert or update or delete on organization_file_result
|
||||
for each row execute function organization_file_result_touch_record();
|
||||
|
||||
alter table asset_operation add column organization_record_id uuid references organization_record (id) on delete set null;
|
||||
create index asset_operation_record_idx on asset_operation (organization_record_id) where organization_record_id is not null;
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
drop index if exists asset_operation_record_idx;
|
||||
alter table asset_operation drop column if exists organization_record_id;
|
||||
${dropTables(["organization_file_result", "organization_record"])}
|
||||
drop function if exists organization_file_result_touch_record();
|
||||
drop function if exists organization_record_bump_change_seq();
|
||||
-- organization_record_change_seq 由 change_seq 列拥有,随表一起删除
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
// 从已迁移的数据库反射出 src/types.ts:列类型 + CHECK (col in (...)) 枚举字面量联合。
|
||||
// 迁移是唯一事实来源;改表后运行 `pnpm --filter @umt/db gen:types`,测试会校验两者无漂移。
|
||||
|
||||
interface ColumnInfo {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
udt_name: string;
|
||||
is_nullable: "YES" | "NO";
|
||||
column_default: string | null;
|
||||
}
|
||||
|
||||
const pascal = (s: string) =>
|
||||
s
|
||||
.split("_")
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
||||
.join("");
|
||||
|
||||
const SCALAR: Record<string, string> = {
|
||||
uuid: "string",
|
||||
text: "string",
|
||||
int4: "number",
|
||||
int8: "number",
|
||||
float8: "number",
|
||||
bool: "boolean",
|
||||
date: "string",
|
||||
bytea: "Buffer",
|
||||
xid8: "string",
|
||||
timestamptz: "Timestamptz",
|
||||
jsonb: "Json",
|
||||
_text: "string[]",
|
||||
};
|
||||
|
||||
/** 解析 `CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))` 为 { col, values } */
|
||||
export function parseEnumCheck(def: string): { column: string; values: string[] } | null {
|
||||
const single = /^CHECK \(\((\w+) = '((?:[^']|'')*)'::text\)\)$/.exec(def);
|
||||
if (single) return { column: single[1]!, values: [single[2]!.replace(/''/g, "'")] };
|
||||
const m = /^CHECK \(\(?\(?(\w+) = ANY \(\(?ARRAY\[(.+)\]\)?(?:::text\[\])?\)\)?\)?\)$/.exec(def);
|
||||
if (!m) return null;
|
||||
const values = [...m[2]!.matchAll(/'((?:[^']|'')*)'::text/g)].map((x) => x[1]!.replace(/''/g, "'"));
|
||||
return values.length ? { column: m[1]!, values } : null;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 反射不依赖应用表类型
|
||||
export async function generateTypesSource(db: Kysely<any>): Promise<string> {
|
||||
const cols = await sql<ColumnInfo>`
|
||||
select table_name, column_name, data_type, udt_name, is_nullable, column_default
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema() and table_name not like 'kysely_%'
|
||||
order by table_name, ordinal_position`.execute(db);
|
||||
const checks = await sql<{ table_name: string; def: string }>`
|
||||
select c.relname as table_name, pg_get_constraintdef(k.oid) as def
|
||||
from pg_constraint k join pg_class c on c.oid = k.conrelid
|
||||
where k.contype = 'c' and c.relnamespace = current_schema()::regnamespace
|
||||
order by c.relname, k.conname`.execute(db);
|
||||
|
||||
const enums = new Map<string, string[]>();
|
||||
for (const { table_name, def } of checks.rows) {
|
||||
const parsed = parseEnumCheck(def);
|
||||
if (parsed) enums.set(`${table_name}.${parsed.column}`, parsed.values);
|
||||
}
|
||||
|
||||
const tables = new Map<string, ColumnInfo[]>();
|
||||
for (const c of cols.rows) {
|
||||
const list = tables.get(c.table_name) ?? [];
|
||||
list.push(c);
|
||||
tables.set(c.table_name, list);
|
||||
}
|
||||
|
||||
const out: string[] = [
|
||||
"// 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。",
|
||||
"// 改表:新增迁移 → pnpm db:migrate → pnpm --filter @umt/db gen:types。",
|
||||
"",
|
||||
'import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";',
|
||||
"",
|
||||
"/** timestamptz:读出为 Date,写入可传 Date 或 ISO 串 */",
|
||||
"export type Timestamptz = ColumnType<Date, Date | string, Date | string>;",
|
||||
"/** 有默认值的 timestamptz(Generated 不会展开嵌套 ColumnType,故单独定义) */",
|
||||
"export type GeneratedTimestamptz = ColumnType<Date, Date | string | undefined, Date | string>;",
|
||||
"/** jsonb:写入数组时须先 JSON.stringify(pg 会把 JS 数组当作 PG 数组) */",
|
||||
"export type Json = unknown;",
|
||||
"",
|
||||
];
|
||||
|
||||
const enumAliases: string[] = [];
|
||||
const body: string[] = [];
|
||||
const names = [...tables.keys()].sort();
|
||||
for (const t of names) {
|
||||
body.push(`export interface ${pascal(t)}Table {`);
|
||||
for (const c of tables.get(t)!) {
|
||||
const key = `${t}.${c.column_name}`;
|
||||
let ty: string;
|
||||
const values = enums.get(key);
|
||||
if (values) {
|
||||
const alias = `${pascal(t)}${pascal(c.column_name)}`;
|
||||
enumAliases.push(`export type ${alias} = ${values.map((v) => JSON.stringify(v)).join(" | ")};`);
|
||||
ty = alias;
|
||||
} else {
|
||||
const s = SCALAR[c.udt_name];
|
||||
if (!s) throw new Error(`未映射的列类型 ${key}: ${c.udt_name}`);
|
||||
ty = s;
|
||||
}
|
||||
if (c.is_nullable === "YES") ty = `${ty} | null`;
|
||||
if (c.column_default !== null) ty = ty === "Timestamptz" ? "GeneratedTimestamptz" : `Generated<${ty}>`;
|
||||
body.push(` ${c.column_name}: ${ty};`);
|
||||
}
|
||||
body.push("}", "");
|
||||
}
|
||||
|
||||
out.push(...enumAliases, "", ...body);
|
||||
out.push("export interface DB {");
|
||||
for (const t of names) out.push(` ${t}: ${pascal(t)}Table;`);
|
||||
out.push("}", "");
|
||||
for (const t of names) {
|
||||
const p = pascal(t);
|
||||
out.push(
|
||||
`export type ${p}Row = Selectable<${p}Table>;`,
|
||||
`export type New${p} = Insertable<${p}Table>;`,
|
||||
`export type ${p}Update = Updateable<${p}Table>;`,
|
||||
);
|
||||
}
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
|
||||
/** 用仓库的 biome 格式化生成结果,保证与 lint 一致 */
|
||||
export function formatWithBiome(source: string): string {
|
||||
return execFileSync(`${ROOT}node_modules/.bin/biome`, ["format", "--stdin-file-path=packages/db/src/types.ts"], {
|
||||
input: source,
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
export const TYPES_FILE = fileURLToPath(new URL("../src/types.ts", import.meta.url));
|
||||
@@ -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();
|
||||
}
|
||||
@@ -2,6 +2,15 @@ import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import type { DB } from "./types";
|
||||
|
||||
// int8 → number(大小/序号都在 2^53 内;超出即报错而不是静默丢精度)
|
||||
pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isSafeInteger(n)) throw new RangeError(`int8 超出安全整数范围: ${v}`);
|
||||
return n;
|
||||
});
|
||||
// date → 原样 'YYYY-MM-DD' 字符串,避免按本地时区转成 Date 造成日期漂移
|
||||
pg.types.setTypeParser(pg.types.builtins.DATE, (v: string) => v);
|
||||
|
||||
export interface CreateDbOptions {
|
||||
/** 默认读 process.env.DATABASE_URL */
|
||||
connectionString?: string;
|
||||
|
||||
@@ -1,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";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
// 迁移辅助:迁移以原生 DDL 书写,便于评审与对照 REQ §12。
|
||||
// 注意:已发布迁移引用的辅助函数行为不得再改变,只能新增函数。
|
||||
|
||||
/** 执行一段不带参数的 DDL(可含多条语句,pg 简单查询协议) */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function execDdl(db: Kysely<any>, ddl: string): Promise<void> {
|
||||
await sql.raw(ddl).execute(db);
|
||||
}
|
||||
|
||||
/** text 列的枚举 CHECK:`check (col in ('a','b'))` */
|
||||
export function oneOf(column: string, values: readonly string[]): string {
|
||||
return `check (${column} in (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")}))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准实体表:uuid 主键 + created_at/updated_at + updated_at 自动刷新触发器。
|
||||
* body 为其余列与表级约束(逗号分隔,不含首尾逗号)。
|
||||
*/
|
||||
export function entityTable(name: string, body: string): string {
|
||||
return `
|
||||
create table ${name} (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
${body.trim()},
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create trigger ${name}_touch_updated_at before update on ${name}
|
||||
for each row execute function umt_touch_updated_at();
|
||||
`;
|
||||
}
|
||||
|
||||
/** 按顺序 drop 表(调用方按依赖逆序传入) */
|
||||
export function dropTables(names: readonly string[]): string {
|
||||
return names.map((n) => `drop table if exists ${n} cascade;`).join("\n");
|
||||
}
|
||||
@@ -7,18 +7,28 @@ import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely
|
||||
/** 迁移目录:packages/db/migrations,文件名 NNNN_name.ts,导出 up/down */
|
||||
export const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
|
||||
|
||||
export interface MigratorOptions {
|
||||
/**
|
||||
* kysely_migration 表所在 schema。默认不指定(走 search_path)。
|
||||
* 在非 public schema 里迁移(如测试隔离 schema)时必须显式指定,
|
||||
* 否则 Kysely 会把 public 下已存在的迁移表当成当前库的迁移记录。
|
||||
*/
|
||||
migrationTableSchema?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型
|
||||
export function createMigrator(db: Kysely<any>): Migrator {
|
||||
export function createMigrator(db: Kysely<any>, opts: MigratorOptions = {}): Migrator {
|
||||
return new Migrator({
|
||||
db,
|
||||
provider: new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR }),
|
||||
...(opts.migrationTableSchema ? { migrationTableSchema: opts.migrationTableSchema } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 迁移到最新;失败时抛出 */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function migrateToLatest(db: Kysely<any>): Promise<MigrationResultSet> {
|
||||
const result = await createMigrator(db).migrateToLatest();
|
||||
export async function migrateToLatest(db: Kysely<any>, opts: MigratorOptions = {}): Promise<MigrationResultSet> {
|
||||
const result = await createMigrator(db, opts).migrateToLatest();
|
||||
if (result.error) throw result.error;
|
||||
return result;
|
||||
}
|
||||
|
||||
+1094
-17
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
import { NO_MIGRATIONS } from "kysely/migration";
|
||||
import pg from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { formatWithBiome, generateTypesSource, TYPES_FILE } from "../scripts/gen-types-lib";
|
||||
import { createMigrator, type DB, migrateToLatest } from "../src";
|
||||
|
||||
// 对真实 PG(默认开发库 55433)跑迁移往返;每次测试用独立 schema,互不干扰且不碰 public。
|
||||
// 无数据库环境可设 UMT_SKIP_DB_TESTS=1 跳过(CI 默认要求数据库可用)。
|
||||
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const skip = process.env.UMT_SKIP_DB_TESTS === "1";
|
||||
const schema = `umt_test_${process.pid}_${Date.now().toString(36)}`;
|
||||
|
||||
const EXPECTED_TABLES = [
|
||||
// 底座(0001/0002)
|
||||
"settings",
|
||||
"api_key",
|
||||
"audit_event",
|
||||
"outbox_event",
|
||||
"secret",
|
||||
// 实例
|
||||
"library_root",
|
||||
"downloader_instance",
|
||||
"media_server_instance",
|
||||
"path_mapping",
|
||||
// 内容 / 身份
|
||||
"media_entity",
|
||||
"content_unit",
|
||||
"title_alias",
|
||||
"external_identity",
|
||||
"identity_revision",
|
||||
// 来源
|
||||
"site",
|
||||
"site_account",
|
||||
"feed_source",
|
||||
"source_release",
|
||||
"observation",
|
||||
// 文件
|
||||
"media_asset",
|
||||
"asset_unit_coverage",
|
||||
"asset_location",
|
||||
"path_binding",
|
||||
// 规则
|
||||
"policy_revision",
|
||||
"subscription",
|
||||
"acquisition_slot",
|
||||
"preference_tag",
|
||||
"preference_suppression",
|
||||
// 执行
|
||||
"task",
|
||||
"acquisition_plan",
|
||||
"download_task",
|
||||
"asset_operation",
|
||||
// 人工/AI
|
||||
"agent_run",
|
||||
"artifact",
|
||||
"decision",
|
||||
"approval",
|
||||
"automation_grant",
|
||||
// 运维 / 整理
|
||||
"reconcile_run",
|
||||
"server_mapping",
|
||||
"organization_record",
|
||||
"organization_file_result",
|
||||
].sort();
|
||||
|
||||
describe.skipIf(skip)("schema migrations (real PG)", () => {
|
||||
let admin: Kysely<unknown>;
|
||||
let db: Kysely<DB>;
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 1 }) }) });
|
||||
await sql.raw(`create schema ${schema}`).execute(admin);
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
|
||||
await admin?.destroy();
|
||||
});
|
||||
|
||||
const tables = async () =>
|
||||
(
|
||||
await sql<{ t: string }>`select table_name as t from information_schema.tables
|
||||
where table_schema = current_schema() and table_type = 'BASE TABLE' and table_name not like 'kysely_%'
|
||||
order by 1`.execute(db)
|
||||
).rows.map((r) => r.t);
|
||||
|
||||
it("建齐任务书 §3.1-2 全部表", async () => {
|
||||
expect(await tables()).toEqual(EXPECTED_TABLES);
|
||||
});
|
||||
|
||||
it("src/types.ts 与迁移结果一致(无漂移)", async () => {
|
||||
const generated = formatWithBiome(await generateTypesSource(db));
|
||||
expect(generated).toBe(readFileSync(TYPES_FILE, "utf8"));
|
||||
});
|
||||
|
||||
it("新实体表都有 uuid 主键默认值与 created_at/updated_at", async () => {
|
||||
const rows = (
|
||||
await sql<{ table_name: string; column_name: string; column_default: string | null }>`
|
||||
select table_name, column_name, column_default from information_schema.columns
|
||||
where table_schema = current_schema() and column_name in ('id', 'created_at', 'updated_at')`.execute(db)
|
||||
).rows;
|
||||
for (const t of EXPECTED_TABLES.filter((x) => !["settings", "audit_event", "outbox_event"].includes(x))) {
|
||||
const cols = rows.filter((r) => r.table_name === t);
|
||||
expect(cols.map((c) => c.column_name).sort(), t).toEqual(["created_at", "id", "updated_at"]);
|
||||
expect(cols.find((c) => c.column_name === "id")?.column_default, t).toBe("gen_random_uuid()");
|
||||
}
|
||||
});
|
||||
|
||||
it("updated_at 触发器与 CHECK 状态约束", async () => {
|
||||
const e = await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "沙丘2", year: 2024 })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await sql`select pg_sleep(0.01)`.execute(db);
|
||||
const u = await db
|
||||
.updateTable("media_entity")
|
||||
.set({ title: "沙丘:第二部" })
|
||||
.where("id", "=", e.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(u.updated_at.getTime()).toBeGreaterThanOrEqual(e.updated_at.getTime());
|
||||
await expect(
|
||||
sql`insert into acquisition_slot (media_entity_id, name, status) values (${e.id}, 'x', 'bogus')`.execute(db),
|
||||
).rejects.toThrow(/check constraint/);
|
||||
});
|
||||
|
||||
it("external_identity:accepted 唯一,候选可并存", async () => {
|
||||
const [a, b] = await db
|
||||
.insertInto("media_entity")
|
||||
.values([
|
||||
{ kind: "movie", title: "A" },
|
||||
{ kind: "movie", title: "B" },
|
||||
])
|
||||
.returning("id")
|
||||
.execute();
|
||||
const base = { provider: "tmdb", entity_type: "movie" as const, external_id: "693134" };
|
||||
await db
|
||||
.insertInto("external_identity")
|
||||
.values([
|
||||
{ ...base, media_entity_id: a!.id, status: "proposed" },
|
||||
{ ...base, media_entity_id: b!.id, status: "proposed" },
|
||||
{ ...base, media_entity_id: a!.id, status: "accepted" },
|
||||
{ ...base, entity_type: "tv", media_entity_id: b!.id, status: "accepted" },
|
||||
])
|
||||
.execute();
|
||||
await expect(
|
||||
db
|
||||
.insertInto("external_identity")
|
||||
.values({ ...base, media_entity_id: b!.id, status: "accepted" })
|
||||
.execute(),
|
||||
).rejects.toThrow(/external_identity_accepted_key/);
|
||||
});
|
||||
|
||||
it("source_release / observation / download_task / Slot 预留 唯一约束", async () => {
|
||||
const site = await db
|
||||
.insertInto("site")
|
||||
.values({ key: "audiences", name: "观众", adapter: "nexusphp", base_url: "https://example.invalid" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const rel = await db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: site.id, site_release_id: "123", title: "Dune.Part.Two.2024.2160p" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await expect(
|
||||
db.insertInto("source_release").values({ site_id: site.id, site_release_id: "123", title: "dup" }).execute(),
|
||||
).rejects.toThrow(/source_release_site_release_key/);
|
||||
|
||||
const feed = await db
|
||||
.insertInto("feed_source")
|
||||
.values({ site_id: site.id, name: "电影", url_display: "https://example.invalid/rss?passkey=[REDACTED]" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const obs = { feed_id: feed.id, guid: "g1", content_digest: "d1", title: "t" };
|
||||
await db.insertInto("observation").values(obs).execute();
|
||||
await expect(db.insertInto("observation").values(obs).execute()).rejects.toThrow(/observation_feed_guid_key/);
|
||||
|
||||
const qb = await db
|
||||
.insertInto("downloader_instance")
|
||||
.values({ name: "qb1", base_url: "http://qb.invalid" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const dt = { instance_id: qb.id, torrent_hash: "a".repeat(40), name: "x" };
|
||||
await db.insertInto("download_task").values(dt).execute();
|
||||
await expect(db.insertInto("download_task").values(dt).execute()).rejects.toThrow(
|
||||
/download_task_instance_hash_key/,
|
||||
);
|
||||
|
||||
const ent = await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "C" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({ media_entity_id: ent.id, name: "archival" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await expect(
|
||||
db.insertInto("acquisition_slot").values({ media_entity_id: ent.id, name: "archival" }).execute(),
|
||||
).rejects.toThrow(/acquisition_slot_target_key/);
|
||||
const plan = (k: string) => ({
|
||||
slot_id: slot.id,
|
||||
source_release_id: rel.id,
|
||||
status: "reserved" as const,
|
||||
payload_hash: "h",
|
||||
idempotency_key: k,
|
||||
});
|
||||
await db.insertInto("acquisition_plan").values(plan("k1")).execute();
|
||||
await expect(db.insertInto("acquisition_plan").values(plan("k2")).execute()).rejects.toThrow(
|
||||
/acquisition_plan_slot_active_key/,
|
||||
);
|
||||
});
|
||||
|
||||
it("path_binding:同作品同季只能有一个 active 绑定", async () => {
|
||||
const root = await db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "movies", local_path: "/media/movies" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const ent = await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "series", title: "S" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const b = { media_entity_id: ent.id, library_root_id: root.id, binding_kind: "entity" as const };
|
||||
await db
|
||||
.insertInto("path_binding")
|
||||
.values({ ...b, relative_path: "S (2023)" })
|
||||
.execute();
|
||||
await expect(
|
||||
db
|
||||
.insertInto("path_binding")
|
||||
.values({ ...b, relative_path: "S2 (2023)" })
|
||||
.execute(),
|
||||
).rejects.toThrow(/path_binding_active_key/);
|
||||
await db
|
||||
.insertInto("path_binding")
|
||||
.values({ ...b, relative_path: "S2 (2023)", status: "conflict" })
|
||||
.execute();
|
||||
});
|
||||
|
||||
it("organization_record.change_seq 随更新与文件明细变化递增", async () => {
|
||||
const rec = await db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "auto", actor_type: "system", action: "copy" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const upd = await db
|
||||
.updateTable("organization_record")
|
||||
.set({ status: "running" })
|
||||
.where("id", "=", rec.id)
|
||||
.returning("change_seq")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(upd.change_seq).toBeGreaterThan(rec.change_seq);
|
||||
await db
|
||||
.insertInto("organization_file_result")
|
||||
.values({ organization_record_id: rec.id, mode: "copy", source_path: "/downloads/a.mkv" })
|
||||
.execute();
|
||||
const after = await db
|
||||
.selectFrom("organization_record")
|
||||
.select("change_seq")
|
||||
.where("id", "=", rec.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(after.change_seq).toBeGreaterThan(upd.change_seq);
|
||||
expect(typeof after.change_seq).toBe("number");
|
||||
});
|
||||
|
||||
it("title_alias 上有 gin trgm 索引,可模糊召回", async () => {
|
||||
const ent = await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "series", title: "最后生还者" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await db
|
||||
.insertInto("title_alias")
|
||||
.values({ media_entity_id: ent.id, alias: "The Last of Us", normalized: "the last of us", source: "tmdb" })
|
||||
.execute();
|
||||
const hit = await sql<{ id: string }>`select media_entity_id as id from title_alias
|
||||
where normalized % ${"last of us"}`.execute(db);
|
||||
expect(hit.rows.map((r) => r.id)).toContain(ent.id);
|
||||
});
|
||||
|
||||
it("迁移可完整 down 再 up(往返)", async () => {
|
||||
const migrator = createMigrator(db, { migrationTableSchema: schema });
|
||||
const down = await migrator.migrateTo(NO_MIGRATIONS);
|
||||
expect(down.error).toBeUndefined();
|
||||
expect(await tables()).toEqual([]);
|
||||
const up = await migrator.migrateToLatest();
|
||||
expect(up.error).toBeUndefined();
|
||||
expect(await tables()).toEqual(EXPECTED_TABLES);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
// 迁移文件由 Kysely FileMigrationProvider 用原生 import() 加载(Node 类型剥离,不做无扩展名解析),
|
||||
// 因此 migrations/ 内相对导入必须写 .ts 扩展名
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src", "test", "scripts", "migrations"]
|
||||
}
|
||||
Reference in new issue
Block a user