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
@@ -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>;
|
||||
Reference in new issue
Block a user