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