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

+140
View File
@@ -0,0 +1,140 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { type Kysely, sql } from "kysely";
// 从已迁移的数据库反射出 src/types.ts:列类型 + CHECK (col in (...)) 枚举字面量联合。
// 迁移是唯一事实来源;改表后运行 `pnpm --filter @umt/db gen:types`,测试会校验两者无漂移。
interface ColumnInfo {
table_name: string;
column_name: string;
data_type: string;
udt_name: string;
is_nullable: "YES" | "NO";
column_default: string | null;
}
const pascal = (s: string) =>
s
.split("_")
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join("");
const SCALAR: Record<string, string> = {
uuid: "string",
text: "string",
int4: "number",
int8: "number",
float8: "number",
bool: "boolean",
date: "string",
bytea: "Buffer",
xid8: "string",
timestamptz: "Timestamptz",
jsonb: "Json",
_text: "string[]",
};
/** 解析 `CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))` 为 { col, values } */
export function parseEnumCheck(def: string): { column: string; values: string[] } | null {
const single = /^CHECK \(\((\w+) = '((?:[^']|'')*)'::text\)\)$/.exec(def);
if (single) return { column: single[1]!, values: [single[2]!.replace(/''/g, "'")] };
const m = /^CHECK \(\(?\(?(\w+) = ANY \(\(?ARRAY\[(.+)\]\)?(?:::text\[\])?\)\)?\)?\)$/.exec(def);
if (!m) return null;
const values = [...m[2]!.matchAll(/'((?:[^']|'')*)'::text/g)].map((x) => x[1]!.replace(/''/g, "'"));
return values.length ? { column: m[1]!, values } : null;
}
// biome-ignore lint/suspicious/noExplicitAny: 反射不依赖应用表类型
export async function generateTypesSource(db: Kysely<any>): Promise<string> {
const cols = await sql<ColumnInfo>`
select table_name, column_name, data_type, udt_name, is_nullable, column_default
from information_schema.columns
where table_schema = current_schema() and table_name not like 'kysely_%'
order by table_name, ordinal_position`.execute(db);
const checks = await sql<{ table_name: string; def: string }>`
select c.relname as table_name, pg_get_constraintdef(k.oid) as def
from pg_constraint k join pg_class c on c.oid = k.conrelid
where k.contype = 'c' and c.relnamespace = current_schema()::regnamespace
order by c.relname, k.conname`.execute(db);
const enums = new Map<string, string[]>();
for (const { table_name, def } of checks.rows) {
const parsed = parseEnumCheck(def);
if (parsed) enums.set(`${table_name}.${parsed.column}`, parsed.values);
}
const tables = new Map<string, ColumnInfo[]>();
for (const c of cols.rows) {
const list = tables.get(c.table_name) ?? [];
list.push(c);
tables.set(c.table_name, list);
}
const out: string[] = [
"// 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。",
"// 改表:新增迁移 → pnpm db:migrate → pnpm --filter @umt/db gen:types。",
"",
'import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";',
"",
"/** timestamptz:读出为 Date,写入可传 Date 或 ISO 串 */",
"export type Timestamptz = ColumnType<Date, Date | string, Date | string>;",
"/** 有默认值的 timestamptz(Generated 不会展开嵌套 ColumnType,故单独定义) */",
"export type GeneratedTimestamptz = ColumnType<Date, Date | string | undefined, Date | string>;",
"/** jsonb:写入数组时须先 JSON.stringify(pg 会把 JS 数组当作 PG 数组) */",
"export type Json = unknown;",
"",
];
const enumAliases: string[] = [];
const body: string[] = [];
const names = [...tables.keys()].sort();
for (const t of names) {
body.push(`export interface ${pascal(t)}Table {`);
for (const c of tables.get(t)!) {
const key = `${t}.${c.column_name}`;
let ty: string;
const values = enums.get(key);
if (values) {
const alias = `${pascal(t)}${pascal(c.column_name)}`;
enumAliases.push(`export type ${alias} = ${values.map((v) => JSON.stringify(v)).join(" | ")};`);
ty = alias;
} else {
const s = SCALAR[c.udt_name];
if (!s) throw new Error(`未映射的列类型 ${key}: ${c.udt_name}`);
ty = s;
}
if (c.is_nullable === "YES") ty = `${ty} | null`;
if (c.column_default !== null) ty = ty === "Timestamptz" ? "GeneratedTimestamptz" : `Generated<${ty}>`;
body.push(` ${c.column_name}: ${ty};`);
}
body.push("}", "");
}
out.push(...enumAliases, "", ...body);
out.push("export interface DB {");
for (const t of names) out.push(` ${t}: ${pascal(t)}Table;`);
out.push("}", "");
for (const t of names) {
const p = pascal(t);
out.push(
`export type ${p}Row = Selectable<${p}Table>;`,
`export type New${p} = Insertable<${p}Table>;`,
`export type ${p}Update = Updateable<${p}Table>;`,
);
}
return `${out.join("\n")}\n`;
}
const ROOT = fileURLToPath(new URL("../../..", import.meta.url));
/** 用仓库的 biome 格式化生成结果,保证与 lint 一致 */
export function formatWithBiome(source: string): string {
return execFileSync(`${ROOT}node_modules/.bin/biome`, ["format", "--stdin-file-path=packages/db/src/types.ts"], {
input: source,
cwd: ROOT,
encoding: "utf8",
});
}
export const TYPES_FILE = fileURLToPath(new URL("../src/types.ts", import.meta.url));
+12
View File
@@ -0,0 +1,12 @@
// 用法:tsx scripts/gen-types.ts —— 对 DATABASE_URL 的当前 schema 反射并重写 src/types.ts
import { writeFileSync } from "node:fs";
import { createDb } from "../src";
import { formatWithBiome, generateTypesSource, TYPES_FILE } from "./gen-types-lib";
const db = createDb({ max: 1 });
try {
writeFileSync(TYPES_FILE, formatWithBiome(await generateTypesSource(db)));
console.log(`已写入 ${TYPES_FILE}`);
} finally {
await db.destroy();
}