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

+166 -2
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { Timestamp } from "./common";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent";
import { Id, NonNegInt, Timestamp } from "./common";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution";
import { OrganizationStatus } from "./organization";
import { FeedHealth } from "./sources";
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
export const EventEnvelope = z.object({
@@ -15,10 +19,30 @@ export type EventEnvelope<P = unknown> = {
payload: P;
};
/** 已知事件类型;业务事件后续按 `域.动作` 追加 */
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
export const EventType = {
heartbeat: "system.heartbeat",
resync: "system.resync",
taskCreated: "task.created",
taskStatusChanged: "task.status_changed",
downloadSubmitted: "download.submitted",
downloadProgress: "download.progress",
downloadStatusChanged: "download.status_changed",
downloadThreshold: "download.threshold",
organizationChanged: "organization.changed",
agentRunStatus: "agent.run_status",
agentTextDelta: "agent.text_delta",
agentToolCall: "agent.tool_call",
feedFetched: "feed.fetched",
feedHealthChanged: "feed.health_changed",
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -26,3 +50,143 @@ export type EventType = (typeof EventType)[keyof typeof EventType];
export const HeartbeatPayload = z.object({
role: z.string(),
});
/** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */
export const ResyncPayload = z.object({ reason: z.string() });
export const TaskCreatedPayload = z.object({
taskId: Id,
kind: TaskKind,
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
});
export const TaskStatusChangedPayload = z.object({
taskId: Id,
kind: TaskKind,
from: TaskStatus.nullable(),
to: TaskStatus,
error: z.string().nullable(),
});
export const DownloadSubmittedPayload = z.object({
downloadTaskId: Id,
instanceId: Id,
torrentHash: z.string(),
planId: Id.nullable(),
name: z.string(),
});
export const DownloadProgressPayload = z.object({
downloadTaskId: Id,
progress: z.number().min(0).max(1),
downloadedBytes: NonNegInt,
sizeBytes: NonNegInt.nullable(),
});
export const DownloadStatusChangedPayload = z.object({
downloadTaskId: Id,
from: DownloadStatus.nullable(),
to: DownloadStatus,
pauseReason: PauseReason.nullable(),
});
/** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */
export const DownloadThresholdPayload = z.object({
volumeKey: z.string(),
state: z.enum(["tripped", "recovered", "unknown"]),
usedPercent: z.number().min(0).max(100).nullable(),
pausedTaskIds: z.array(Id),
});
/** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */
export const OrganizationChangedPayload = z.object({
recordId: Id,
status: OrganizationStatus,
changeSeq: z.string().regex(/^\d+$/),
mediaEntityId: Id.nullable(),
});
export const AgentRunStatusPayload = z.object({
runId: Id,
status: AgentRunStatus,
summary: z.string().nullable(),
});
/** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */
export const AgentTextDeltaPayload = z.object({
runId: Id,
runSeq: NonNegInt,
delta: z.string(),
});
export const AgentToolCallPayload = z.object({
runId: Id,
runSeq: NonNegInt,
capabilityId: z.string(),
phase: z.enum(["started", "succeeded", "failed", "denied"]),
/** 已脱敏的摘要,不含凭据/私有 URL */
summary: z.string(),
});
export const FeedFetchedPayload = z.object({
feedId: Id,
newItems: NonNegInt,
updatedItems: NonNegInt,
notModified: z.boolean(),
});
export const FeedHealthChangedPayload = z.object({
feedId: Id,
from: FeedHealth.nullable(),
to: FeedHealth,
reason: z.string().nullable(),
});
export const DecisionCreatedPayload = z.object({
decisionId: Id,
kind: DecisionKind,
severity: Severity,
title: z.string(),
});
export const DecisionUpdatedPayload = z.object({
decisionId: Id,
status: DecisionStatus,
});
/** 事件类型 → payload schema */
export const EVENT_PAYLOADS = {
"system.heartbeat": HeartbeatPayload,
"system.resync": ResyncPayload,
"task.created": TaskCreatedPayload,
"task.status_changed": TaskStatusChangedPayload,
"download.submitted": DownloadSubmittedPayload,
"download.progress": DownloadProgressPayload,
"download.status_changed": DownloadStatusChangedPayload,
"download.threshold": DownloadThresholdPayload,
"organization.changed": OrganizationChangedPayload,
"agent.run_status": AgentRunStatusPayload,
"agent.text_delta": AgentTextDeltaPayload,
"agent.tool_call": AgentToolCallPayload,
"feed.fetched": FeedFetchedPayload,
"feed.health_changed": FeedHealthChangedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
/** 已知事件的判别联合:`{ type, payload }` */
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[EventType];
/** 已知事件信封的 schema(判别联合) */
export const TypedEventEnvelope = z.discriminatedUnion(
"type",
Object.entries(EVENT_PAYLOADS).map(([type, payload]) =>
z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }),
) as unknown as [z.ZodObject, ...z.ZodObject[]],
);
export function isEventType(type: string): type is EventType {
return Object.hasOwn(EVENT_PAYLOADS, type);
}
/** 校验一个事件 payload;不合法返回 null */
export function parseEventPayload<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
const schema: z.ZodType = EVENT_PAYLOADS[type];
const r = schema.safeParse(payload);
return r.success ? (r.data as EventPayload<T>) : null;
}