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,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