forked from EduCraft/curriculum-project-hub
feat(hub): Prisma schema + DB singleton,对齐 spec/System 契约
按 ADR-0001..0004/0017 重塑 schema,非照搬旧服务: - ADR-0001: ProjectGroupBinding project↔chat 1:1(chatId @unique 钉单射不变式) - ADR-0002: ProjectAgentLock projectId @id(at-most-one)+runId @unique;holder=run 非 session - ADR-0004: PermissionGrant(resource×principal×role)+PermissionSettings(六旋钮)新落; PermissionRole=read/edit/manage 与 PlatformRole=admin/teacher 分离(旧 schema 混淆了) - ADR-0017: AgentSession 无 claudeSessionId,改 provider+model(切 model 即新 session) - RunState 对齐 spec:加 WAITING_FOR_USER/TIMED_OUT(枚举完整性 OPEN) - Audit:runId 是 PINNED 关系,其余 OPEN 旧 schema 的 FeishuProjectBinding(user/chat 混表)拆为单一 ProjectGroupBinding。 prisma validate 通过;generate 通过;tsc --noEmit rc=0; 类型级 schema 约束检查全部通过(ADR-0002 主键/唯一、ADR-0017 无 claudeSessionId 等)。
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Hub runtime configuration. Copy to .env and fill in.
|
||||||
|
|
||||||
|
# PostgreSQL connection string. Used by Prisma and the Hub server.
|
||||||
|
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||||
|
|
||||||
|
# OpenRouter (or any OpenAI-compatible) API key + base URL.
|
||||||
|
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the
|
||||||
|
# OpenAI-compatible client at OpenRouter by default.
|
||||||
|
OPENROUTER_API_KEY=""
|
||||||
|
# Optional: override the base URL (e.g. direct vendor API, local gateway).
|
||||||
|
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
# Hub server port. Defaults to 8788.
|
||||||
|
PORT=8788
|
||||||
Generated
+1080
-2
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -5,18 +5,24 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=20" },
|
"engines": { "node": ">=20" },
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openai": "^4.77.0"
|
"openai": "^4.77.0",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"@prisma/client": "^6.19.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0",
|
||||||
|
"prisma": "^6.19.3"
|
||||||
},
|
},
|
||||||
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"check": "tsc -p tsconfig.json --noEmit"
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||||
|
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
||||||
|
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// Prisma schema for Curriculum Project Hub.
|
||||||
|
//
|
||||||
|
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||||
|
// legacy teaching-material-host-service schema, each deliberate:
|
||||||
|
//
|
||||||
|
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
|
||||||
|
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
|
||||||
|
// = new session, so the unique constraint is on the session id alone.
|
||||||
|
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
|
||||||
|
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
|
||||||
|
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||||
|
// user/chat targets into one binding table.
|
||||||
|
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
|
||||||
|
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
|
||||||
|
// completeness OPEN — add states without a schema migration war).
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Platform identity ---------------------------------------------------
|
||||||
|
|
||||||
|
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
feishuOpenId String @unique
|
||||||
|
displayName String
|
||||||
|
avatarUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
platformRoles PlatformRoleAssignment[]
|
||||||
|
createdProjects Project[] @relation("projectCreator")
|
||||||
|
requestedRuns AgentRun[] @relation("runRequester")
|
||||||
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
|
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||||
|
auditEntries AuditEntry[] @relation("auditActor")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
||||||
|
/// `admin` is the only override path for force-release (spec RequiresAdmin).
|
||||||
|
model PlatformRoleAssignment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
role PlatformRole
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
@@index([role, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlatformRole {
|
||||||
|
ADMIN
|
||||||
|
TEACHER
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
workspaceDir String
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
groupBinding ProjectGroupBinding?
|
||||||
|
agentSessions AgentSession[]
|
||||||
|
agentRuns AgentRun[]
|
||||||
|
agentLock ProjectAgentLock?
|
||||||
|
permissionGrants PermissionGrant[] @relation("projectGrants")
|
||||||
|
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||||
|
|
||||||
|
@@index([archivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
|
||||||
|
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
|
||||||
|
/// The "at most one binding per project" half is enforced by the 1:1 relation.
|
||||||
|
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
|
||||||
|
/// here; archival is a future policy, not a current column.
|
||||||
|
model ProjectGroupBinding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String @unique
|
||||||
|
chatId String @unique
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([chatId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||||
|
|
||||||
|
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
|
||||||
|
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
|
||||||
|
/// (ADR-0002); a switch ⇒ new session.
|
||||||
|
model AgentSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
provider String
|
||||||
|
model String
|
||||||
|
title String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
runs AgentRun[]
|
||||||
|
|
||||||
|
@@index([projectId, archivedAt])
|
||||||
|
@@index([provider, model])
|
||||||
|
@@index([updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
|
||||||
|
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
|
||||||
|
/// spec breach. DB mirrors the current enum.
|
||||||
|
enum AgentRunStatus {
|
||||||
|
ACTIVE
|
||||||
|
WAITING_FOR_USER
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
TIMED_OUT
|
||||||
|
CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentEntrypoint {
|
||||||
|
FEISHU
|
||||||
|
WEB
|
||||||
|
CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
model AgentRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
sessionId String?
|
||||||
|
requestedByUserId String?
|
||||||
|
entrypoint AgentEntrypoint
|
||||||
|
status AgentRunStatus @default(ACTIVE)
|
||||||
|
prompt String
|
||||||
|
model String
|
||||||
|
provider String
|
||||||
|
summary String?
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
metadata Json
|
||||||
|
error String?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||||
|
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
projectLock ProjectAgentLock?
|
||||||
|
|
||||||
|
@@index([projectId, status])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([requestedByUserId])
|
||||||
|
@@index([updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
|
||||||
|
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
|
||||||
|
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||||
|
/// app-level invariant checked on read/write, not a DB constraint.
|
||||||
|
model ProjectAgentLock {
|
||||||
|
projectId String @id
|
||||||
|
runId String @unique
|
||||||
|
holderUserId String?
|
||||||
|
acquiredAt DateTime @default(now())
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||||
|
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission grants & settings (ADR-0004) ----------------------------
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
|
||||||
|
/// Distinct from PlatformRole. Force-release is admin-only, outside this
|
||||||
|
/// lattice (spec RequiresAdmin).
|
||||||
|
enum PermissionRole {
|
||||||
|
READ
|
||||||
|
EDIT
|
||||||
|
MANAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 resource_type: project | artifact | project_group. The resource
|
||||||
|
/// id's meaning is determined by its type (artifact id semantics align to the
|
||||||
|
/// Courseware half; OPEN here).
|
||||||
|
enum PermissionResourceType {
|
||||||
|
PROJECT
|
||||||
|
ARTIFACT
|
||||||
|
PROJECT_GROUP
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||||
|
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
|
||||||
|
/// The compound unique covers "one active grant per (resource, principal, role)"
|
||||||
|
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
|
||||||
|
/// row, not a conflict.
|
||||||
|
model PermissionGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
resourceType PermissionResourceType
|
||||||
|
resourceId String
|
||||||
|
principal String
|
||||||
|
role PermissionRole
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([resourceType, resourceId, principal, role, revokedAt])
|
||||||
|
@@index([resourceType, resourceId, revokedAt])
|
||||||
|
@@index([principal, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||||
|
/// opaque string column each; the enum/value-domain is decided by admin policy,
|
||||||
|
/// not by this schema. `resourceType`+`resourceId` identify the resource.
|
||||||
|
/// Related to Project only when resourceType=PROJECT (null otherwise).
|
||||||
|
model PermissionSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
resourceType PermissionResourceType
|
||||||
|
resourceId String
|
||||||
|
externalShare String
|
||||||
|
comment String
|
||||||
|
copyDownload String
|
||||||
|
collaboratorMgmt String
|
||||||
|
agentTrigger String
|
||||||
|
agentCancel String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([resourceType, resourceId])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||||
|
|
||||||
|
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
||||||
|
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
|
||||||
|
/// PINNED relation; the rest is open-shaped columns.
|
||||||
|
model AuditEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
runId String?
|
||||||
|
actorUserId String?
|
||||||
|
action String
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([runId])
|
||||||
|
@@index([actorUserId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Prisma client singleton.
|
||||||
|
*
|
||||||
|
* Prisma's client is a connection-pooled datasource; constructing it per-query
|
||||||
|
* exhausts connections. This module exports one shared instance per process.
|
||||||
|
* Hot-reload (tsx watch) can re-import this module — the global guard prevents
|
||||||
|
* a second PrismaClient from being created.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { __hubPrisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma: PrismaClient =
|
||||||
|
globalForPrisma.__hubPrisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env["HUB_PRISMA_LOG"] !== undefined ? ["query", "error", "warn"] : ["error", "warn"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
|
globalForPrisma.__hubPrisma = prisma;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user