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:
commit
b22c440139
138 files changed
+14455
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# @umt/adapters
|
||||
|
||||
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/adapters",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/adapters 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/adapters";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/adapters");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/agent
|
||||
|
||||
Pi Agent 接入:AgentRuntimePort 封装、能力注册中心桥接、agent_run 持久化与测试用 fake 模型 provider。只通过显式白名单能力操作业务,不开放 bash/文件系统/任意网络。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/agent 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/agent";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/agent");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/contracts
|
||||
|
||||
所有 API、SSE 事件与能力(capability)输入输出的唯一定义(zod 4 schema + 推导出的 TS 类型)。web 与 server 共用,任何一端都不得手写重复 DTO;新增接口先在这里加 schema,再由 server 用 `@hono/zod-openapi` 生成 OpenAPI。
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@umt/contracts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* 能力调用上下文(REQ §10.3)。产品自有接口,不是 Pi SDK 原生 API。
|
||||
* signal 由任务取消传播而来。
|
||||
*/
|
||||
export interface CapabilityContext {
|
||||
actorId: string;
|
||||
taskId: string;
|
||||
scopeIds: string[];
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** 变更计划:由业务端生成与保存,审批绑定 id + payloadHash + expectedRevision */
|
||||
export const MutationPlan = z.object({
|
||||
id: z.string(),
|
||||
operation: z.string(),
|
||||
targetIds: z.array(z.string()),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
evidenceIds: z.array(z.string()),
|
||||
payloadHash: z.string(),
|
||||
requiresApproval: z.boolean(),
|
||||
});
|
||||
export type MutationPlan = z.infer<typeof MutationPlan>;
|
||||
|
||||
/** 能力风险级别 */
|
||||
export const CapabilityRisk = z.enum(["read", "preview", "write", "destructive"]);
|
||||
export type CapabilityRisk = z.infer<typeof CapabilityRisk>;
|
||||
|
||||
/** 能力 ID:`域.动作`,如 download.submit */
|
||||
export const CapabilityId = z.string().regex(/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/);
|
||||
export type CapabilityId = z.infer<typeof CapabilityId>;
|
||||
|
||||
/** 能力定义(注册中心元数据;处理器在 server 侧挂载) */
|
||||
export interface CapabilityDefinition<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {
|
||||
id: CapabilityId;
|
||||
version: number;
|
||||
description: string;
|
||||
input: I;
|
||||
output: O;
|
||||
scopes: string[];
|
||||
risk: CapabilityRisk;
|
||||
idempotent: boolean;
|
||||
supportsPreview: boolean;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** 本地实体 ID 一律 UUID */
|
||||
export const Id = z.uuid();
|
||||
export type Id = z.infer<typeof Id>;
|
||||
|
||||
/** ISO 8601 时间串 */
|
||||
export const Timestamp = z.iso.datetime({ offset: true });
|
||||
export type Timestamp = z.infer<typeof Timestamp>;
|
||||
|
||||
/** 游标分页请求:cursor 为不透明字符串,由服务端编码 */
|
||||
export const CursorQuery = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
export type CursorQuery = z.infer<typeof CursorQuery>;
|
||||
|
||||
/** 游标分页结果:nextCursor 为 null 表示已到末尾 */
|
||||
export const cursorPage = <T extends z.ZodType>(item: T) =>
|
||||
z.object({
|
||||
items: z.array(item),
|
||||
nextCursor: z.string().nullable(),
|
||||
});
|
||||
export type CursorPage<T> = { items: T[]; nextCursor: string | null };
|
||||
|
||||
/** 错误码表:新增错误码只在这里加 */
|
||||
export const ApiErrorCode = z.enum([
|
||||
"bad_request",
|
||||
"validation_failed",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"precondition_failed",
|
||||
"rate_limited",
|
||||
"automation_disabled",
|
||||
"upstream_unavailable",
|
||||
"internal",
|
||||
]);
|
||||
export type ApiErrorCode = z.infer<typeof ApiErrorCode>;
|
||||
|
||||
/** 错误码对应的 HTTP 状态 */
|
||||
export const API_ERROR_STATUS = {
|
||||
bad_request: 400,
|
||||
validation_failed: 422,
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
not_found: 404,
|
||||
conflict: 409,
|
||||
precondition_failed: 412,
|
||||
rate_limited: 429,
|
||||
automation_disabled: 423,
|
||||
upstream_unavailable: 502,
|
||||
internal: 500,
|
||||
} as const satisfies Record<ApiErrorCode, number>;
|
||||
|
||||
export const ApiError = z.object({
|
||||
code: ApiErrorCode,
|
||||
message: z.string(),
|
||||
});
|
||||
export type ApiError = z.infer<typeof ApiError>;
|
||||
|
||||
/** 统一响应信封 */
|
||||
export const apiResponse = <T extends z.ZodType>(data: T) =>
|
||||
z.object({
|
||||
ok: z.boolean(),
|
||||
data: data.optional(),
|
||||
error: ApiError.optional(),
|
||||
});
|
||||
export type ApiResponse<T> =
|
||||
| { ok: true; data: T; error?: undefined }
|
||||
| { ok: false; data?: undefined; error: ApiError };
|
||||
|
||||
/** 没有 data 的失败响应 schema(OpenAPI 错误分支用) */
|
||||
export const ApiErrorResponse = z.object({
|
||||
ok: z.literal(false),
|
||||
error: ApiError,
|
||||
});
|
||||
|
||||
/** API Key 权限范围 */
|
||||
export const ApiScope = z.enum(["read", "organize.read", "organize.execute", "admin"]);
|
||||
export type ApiScope = z.infer<typeof ApiScope>;
|
||||
|
||||
/** 健康检查 */
|
||||
export const HealthStatus = z.object({
|
||||
status: z.enum(["ok", "degraded"]),
|
||||
version: z.string(),
|
||||
role: z.enum(["api", "worker", "all"]),
|
||||
uptimeSec: z.number(),
|
||||
db: z.enum(["ok", "down", "unknown"]),
|
||||
time: Timestamp,
|
||||
});
|
||||
export type HealthStatus = z.infer<typeof HealthStatus>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { Timestamp } from "./common";
|
||||
|
||||
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
|
||||
export const EventEnvelope = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
type: z.string().min(1),
|
||||
at: Timestamp,
|
||||
payload: z.unknown(),
|
||||
});
|
||||
export type EventEnvelope<P = unknown> = {
|
||||
seq: number;
|
||||
type: string;
|
||||
at: string;
|
||||
payload: P;
|
||||
};
|
||||
|
||||
/** 已知事件类型;业务事件后续按 `域.动作` 追加 */
|
||||
export const EventType = {
|
||||
heartbeat: "system.heartbeat",
|
||||
resync: "system.resync",
|
||||
} as const;
|
||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
|
||||
/** 心跳 payload */
|
||||
export const HeartbeatPayload = z.object({
|
||||
role: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./capability";
|
||||
export * from "./common";
|
||||
export * from "./events";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { API_ERROR_STATUS, apiResponse, CapabilityId, CursorQuery, EventEnvelope, Id } from "../src";
|
||||
|
||||
describe("contracts", () => {
|
||||
it("Id 只接受 uuid", () => {
|
||||
expect(Id.safeParse("7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60").success).toBe(true);
|
||||
expect(Id.safeParse("abc").success).toBe(false);
|
||||
});
|
||||
|
||||
it("CursorQuery 默认 limit 50 且限制上限", () => {
|
||||
expect(CursorQuery.parse({}).limit).toBe(50);
|
||||
expect(CursorQuery.safeParse({ limit: 1000 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("ApiResponse 信封", () => {
|
||||
const s = apiResponse(z.object({ n: z.number() }));
|
||||
expect(s.parse({ ok: true, data: { n: 1 } }).data?.n).toBe(1);
|
||||
expect(s.safeParse({ ok: false, error: { code: "nope", message: "" } }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("错误码都有 HTTP 状态", () => {
|
||||
expect(API_ERROR_STATUS.not_found).toBe(404);
|
||||
});
|
||||
|
||||
it("事件信封与能力 ID", () => {
|
||||
expect(
|
||||
EventEnvelope.safeParse({ seq: 1, type: "system.heartbeat", at: new Date().toISOString(), payload: {} }).success,
|
||||
).toBe(true);
|
||||
expect(CapabilityId.safeParse("download.submit").success).toBe(true);
|
||||
expect(CapabilityId.safeParse("Download").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/db
|
||||
|
||||
PostgreSQL 访问层:Kysely 客户端与连接池、手写的 DB 表类型(`src/types.ts`)、迁移文件(`migrations/`)与迁移器。表名 snake_case,与 REQ §12 实体名一致;所有 schema 变更只能通过新增迁移完成,已发布迁移不得修改。
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }) });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test", "scripts", "migrations"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/domain
|
||||
|
||||
纯业务逻辑(无 IO):发布标题解析、身份匹配、Slot 满足度、路径绑定、整理计划与策略评估。禁止 import 任何网络/文件/数据库库,保证 100% 可单元测试。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/domain",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/domain 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/domain";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/domain");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/tasks
|
||||
|
||||
持久任务定义与执行器(OpenWorkflow 或自研 PG 租约表,结论见 ADR-002)与 outbox 投递。负责幂等键、重试、取消传播与重启恢复。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/tasks",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/tasks 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/tasks";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/tasks");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @umt/testkit
|
||||
|
||||
测试夹具与 mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB 与临时目录夹具。只供测试依赖,不进入生产构建。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@umt/testkit",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @umt/testkit 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/testkit";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PACKAGE_NAME } from "../src";
|
||||
|
||||
it("包入口可加载", () => {
|
||||
expect(PACKAGE_NAME).toBe("@umt/testkit");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in new issue
Block a user