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