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