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:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:37 +08:00
1 parent 7ba712ea55
commit cc8b3a7df4
42 files changed
+7026 -25

No files matched your search

+198
View File
@@ -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);
}
});
});
+215
View File
@@ -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");
});
});