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