chore: scaffold monorepo and migrate uhd_web design system (W0-A)

- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
umtandClaude Fable 5.1 committed 2026-09-27 17:36:26 +08:00
commit b22c440139
138 files changed
+14455

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# @umt/db
PostgreSQL 访问层:Kysely 客户端与连接池、手写的 DB 表类型(`src/types.ts`)、迁移文件(`migrations/`)与迁移器。表名 snake_case,与 REQ §12 实体名一致;所有 schema 变更只能通过新增迁移完成,已发布迁移不得修改。
+67
View File
@@ -0,0 +1,67 @@
import { type Kysely, sql } from "kysely";
// 首个迁移:底座四张表。gen_random_uuid() 为 PG13+ 内置
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("settings")
.addColumn("key", "text", (c) => c.primaryKey())
.addColumn("value", "jsonb", (c) => c.notNull())
.addColumn("updated_at", "timestamptz", (c) => c.notNull().defaultTo(sql`now()`))
.execute();
await db.schema
.createTable("api_key")
.addColumn("id", "uuid", (c) => c.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("name", "text", (c) => c.notNull())
.addColumn("key_hash", "text", (c) => c.notNull().unique())
.addColumn("scopes", sql`text[]`, (c) => c.notNull().defaultTo(sql`'{}'::text[]`))
.addColumn("created_at", "timestamptz", (c) => c.notNull().defaultTo(sql`now()`))
.addColumn("revoked_at", "timestamptz")
.addColumn("last_used_at", "timestamptz")
.execute();
await db.schema
.createTable("audit_event")
.addColumn("id", "uuid", (c) => c.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("at", "timestamptz", (c) => c.notNull().defaultTo(sql`now()`))
.addColumn("actor_type", "text", (c) => c.notNull())
.addColumn("actor_id", "text")
.addColumn("action", "text", (c) => c.notNull())
.addColumn("target_type", "text")
.addColumn("target_id", "text")
.addColumn("payload", "jsonb", (c) => c.notNull().defaultTo(sql`'{}'::jsonb`))
.addColumn("trace_id", "text")
.execute();
await db.schema.createIndex("audit_event_at_idx").on("audit_event").column("at").execute();
await db.schema
.createIndex("audit_event_target_idx")
.on("audit_event")
.columns(["target_type", "target_id"])
.execute();
await db.schema
.createTable("outbox_event")
.addColumn("id", "uuid", (c) => c.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("at", "timestamptz", (c) => c.notNull().defaultTo(sql`now()`))
.addColumn("type", "text", (c) => c.notNull())
.addColumn("payload", "jsonb", (c) => c.notNull().defaultTo(sql`'{}'::jsonb`))
.addColumn("published_at", "timestamptz")
.execute();
// 只索引未投递的事件,投递器按时间顺序扫描
await db.schema
.createIndex("outbox_event_pending_idx")
.on("outbox_event")
.column("at")
.where(sql.ref("published_at"), "is", null)
.execute();
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("outbox_event").ifExists().execute();
await db.schema.dropTable("audit_event").ifExists().execute();
await db.schema.dropTable("api_key").ifExists().execute();
await db.schema.dropTable("settings").ifExists().execute();
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@umt/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run",
"migrate": "tsx --env-file-if-exists=../../.env scripts/migrate.ts latest",
"reset": "tsx --env-file-if-exists=../../.env scripts/migrate.ts reset"
},
"dependencies": {
"kysely": "^0.29.6",
"pg": "^8.23.0"
},
"devDependencies": {
"@types/pg": "^8.23.1"
}
}
+23
View File
@@ -0,0 +1,23 @@
// 用法:tsx scripts/migrate.ts latest|reset
import { createDb, migrateToLatest, resetSchema } from "../src";
const cmd = process.argv[2] ?? "latest";
const db = createDb({ max: 1 });
try {
if (cmd === "reset") {
if (process.env.NODE_ENV === "production") throw new Error("生产环境禁止 db:reset");
await resetSchema(db);
console.log("已清空 public schema");
} else if (cmd !== "latest") {
throw new Error(`未知命令: ${cmd}`);
}
const { results } = await migrateToLatest(db);
for (const r of results ?? []) console.log(`${r.status}\t${r.migrationName}`);
if (!results?.length) console.log("已是最新,无待执行迁移");
} catch (err) {
console.error(err);
process.exitCode = 1;
} finally {
await db.destroy();
}
+17
View File
@@ -0,0 +1,17 @@
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import type { DB } from "./types";
export interface CreateDbOptions {
/** 默认读 process.env.DATABASE_URL */
connectionString?: string;
max?: number;
}
/** 创建带连接池的 Kysely 实例;调用方负责在退出时 db.destroy() */
export function createDb(opts: CreateDbOptions = {}): Kysely<DB> {
const connectionString = opts.connectionString ?? process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL 未设置");
const pool = new pg.Pool({ connectionString, max: opts.max ?? 10 });
return new Kysely<DB>({ dialect: new PostgresDialect({ pool }) });
}
+5
View File
@@ -0,0 +1,5 @@
// 统一从本包取 Kysely 与 sql,避免各包各装一份 kysely
export { type Kysely, sql, type Transaction } from "kysely";
export { type CreateDbOptions, createDb } from "./client";
export { createMigrator, MIGRATIONS_DIR, migrateToLatest, resetSchema } from "./migrator";
export type * from "./types";
+31
View File
@@ -0,0 +1,31 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type Kysely, sql } from "kysely";
import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely/migration";
/** 迁移目录:packages/db/migrations,文件名 NNNN_name.ts,导出 up/down */
export const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
// biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型
export function createMigrator(db: Kysely<any>): Migrator {
return new Migrator({
db,
provider: new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR }),
});
}
/** 迁移到最新;失败时抛出 */
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function migrateToLatest(db: Kysely<any>): Promise<MigrationResultSet> {
const result = await createMigrator(db).migrateToLatest();
if (result.error) throw result.error;
return result;
}
/** 清空 public schema(仅开发/测试用) */
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function resetSchema(db: Kysely<any>): Promise<void> {
await sql`drop schema if exists public cascade`.execute(db);
await sql`create schema public`.execute(db);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";
// 手写的表类型,与 migrations/ 保持同步;后续表多了再考虑 kysely-codegen
type Timestamptz = ColumnType<Date, Date | string | undefined, Date | string>;
type Json = ColumnType<unknown, unknown, unknown>;
export interface SettingsTable {
key: string;
value: Json;
updated_at: Timestamptz;
}
export interface ApiKeyTable {
id: Generated<string>;
name: string;
key_hash: string;
scopes: string[];
created_at: Timestamptz;
revoked_at: Date | null;
last_used_at: Date | null;
}
export interface AuditEventTable {
id: Generated<string>;
at: Timestamptz;
actor_type: string;
actor_id: string | null;
action: string;
target_type: string | null;
target_id: string | null;
payload: Json;
trace_id: string | null;
}
export interface OutboxEventTable {
id: Generated<string>;
at: Timestamptz;
type: string;
payload: Json;
published_at: Date | null;
}
export interface DB {
settings: SettingsTable;
api_key: ApiKeyTable;
audit_event: AuditEventTable;
outbox_event: OutboxEventTable;
}
export type ApiKeyRow = Selectable<ApiKeyTable>;
export type NewApiKey = Insertable<ApiKeyTable>;
export type ApiKeyUpdate = Updateable<ApiKeyTable>;
export type AuditEventRow = Selectable<AuditEventTable>;
export type NewAuditEvent = Insertable<AuditEventTable>;
export type OutboxEventRow = Selectable<OutboxEventTable>;
export type SettingsRow = Selectable<SettingsTable>;
+16
View File
@@ -0,0 +1,16 @@
import { readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { MIGRATIONS_DIR } from "../src";
describe("migrations", () => {
it("迁移文件按 NNNN_name.ts 命名且导出 up/down", async () => {
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".ts"));
expect(files.length).toBeGreaterThan(0);
for (const f of files) {
expect(f).toMatch(/^\d{4}_[a-z0-9_]+\.ts$/);
const mod = await import(`${MIGRATIONS_DIR}/${f}`);
expect(typeof mod.up).toBe("function");
expect(typeof mod.down).toBe("function");
}
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test", "scripts", "migrations"]
}