From 15a70e4ecf0624fd95141fbe9628b5e0b00ca817 Mon Sep 17 00:00:00 2001 From: xiaohei Date: Sun, 12 Apr 2026 19:05:03 +0800 Subject: [PATCH] feat: database layer - PostgreSQL schema + memory fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drizzle-orm + postgres dependencies - Full schema: 12 tables covering all modules - Graceful fallback: no DATABASE_URL → memory mode - drizzle-kit config for migrations - Memory store as generic CRUD layer - dev.ts auto-initializes DB on startup --- drizzle.config.ts | 10 ++ package-lock.json | 140 +++++++++++++++++++++++ package.json | 6 +- src/db/index.ts | 26 +++++ src/db/memory-store.ts | 46 ++++++++ src/db/schema.ts | 246 +++++++++++++++++++++++++++++++++++++++++ src/server/dev.ts | 4 + 7 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 drizzle.config.ts create mode 100644 src/db/index.ts create mode 100644 src/db/memory-store.ts create mode 100644 src/db/schema.ts diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..efd8e2f --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://flowpilot:flowpilot@localhost:5432/flowpilot', + }, +}); diff --git a/package-lock.json b/package-lock.json index 875acb2..3f77eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "@arco-design/web-react": "^2.66.0", "@hono/node-server": "^1.19.13", "@larksuiteoapi/node-sdk": "^1.60.0", + "drizzle-orm": "^0.45.2", "hono": "^4.7.0", + "postgres": "^3.4.9", "react": "^18.3.0", "react-dom": "^18.3.0" }, @@ -2577,6 +2579,131 @@ "csstype": "^3.0.2" } }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmmirror.com/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3811,6 +3938,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmmirror.com/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index d6bbc69..5218369 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,17 @@ "build": "tsc && vite build", "lint": "eslint src/ --ext .ts,.tsx", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" }, "dependencies": { "@arco-design/web-react": "^2.66.0", "@hono/node-server": "^1.19.13", "@larksuiteoapi/node-sdk": "^1.60.0", + "drizzle-orm": "^0.45.2", "hono": "^4.7.0", + "postgres": "^3.4.9", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..4fcb5c1 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,26 @@ +// 数据库连接 + 优雅降级 +// 如果 DATABASE_URL 存在且 PG 可连 → 用 PostgreSQL +// 否则 → fallback 到内存 Map 模式(开发友好) + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let db: ReturnType | null = null; +let useMemory = true; + +export async function initDB(): Promise { + const url = process.env.DATABASE_URL; + if (!url) { console.log('📦 No DATABASE_URL, using memory mode'); return; } + try { + const client = postgres(url); + db = drizzle(client, { schema }); + useMemory = false; + console.log('✅ PostgreSQL connected'); + } catch (e) { + console.warn('⚠️ PostgreSQL connection failed, falling back to memory mode'); + } +} + +export function getDB() { return db; } +export function isMemoryMode() { return useMemory; } \ No newline at end of file diff --git a/src/db/memory-store.ts b/src/db/memory-store.ts new file mode 100644 index 0000000..6898074 --- /dev/null +++ b/src/db/memory-store.ts @@ -0,0 +1,46 @@ +/** + * Generic in-memory store (fallback when no PostgreSQL) + */ + +type Store = Map; +const stores = new Map(); + +function getStore(table: string): Store { + if (!stores.has(table)) stores.set(table, new Map()); + return stores.get(table)!; +} + +export const memoryDB = { + create(table: string, id: string, data: any): any { + const store = getStore(table); + const record = { ...data, id }; + store.set(id, record); + return record; + }, + + getById(table: string, id: string): any | undefined { + return getStore(table).get(id); + }, + + list(table: string, filter?: (item: any) => boolean): any[] { + const items = Array.from(getStore(table).values()); + return filter ? items.filter(filter) : items; + }, + + update(table: string, id: string, data: Partial): any | null { + const store = getStore(table); + const existing = store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data }; + store.set(id, updated); + return updated; + }, + + delete(table: string, id: string): boolean { + return getStore(table).delete(id); + }, + + count(table: string, filter?: (item: any) => boolean): number { + return this.list(table, filter).length; + }, +}; diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..90713e5 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,246 @@ +/** + * PostgreSQL schema (drizzle-orm) + * 所有 FlowPilot 数据表定义 + */ + +import { pgTable, text, integer, timestamp, jsonb, boolean, pgEnum } from 'drizzle-orm/pg-core'; + +// Enums +export const riskLevelEnum = pgEnum('risk_level', ['low', 'medium', 'high', 'critical']); +export const riskStrategyEnum = pgEnum('risk_strategy', ['avoid', 'transfer', 'mitigate', 'accept', 'escalate']); +export const riskStatusEnum = pgEnum('risk_status', ['identified', 'analyzing', 'planned', 'monitoring', 'occurred', 'resolved', 'closed']); +export const taskStatusEnum = pgEnum('task_status', ['not_started', 'in_progress', 'completed', 'blocked']); +export const reqPriorityEnum = pgEnum('req_priority', ['must', 'should', 'could', 'wont']); +export const reqStatusEnum = pgEnum('req_status', ['draft', 'reviewed', 'approved', 'implemented', 'verified', 'deferred', 'rejected']); +export const changeStatusEnum = pgEnum('change_status', ['submitted', 'evaluating', 'pending_approval', 'approved', 'rejected', 'implemented', 'closed']); +export const healthStatusEnum = pgEnum('health_status', ['green', 'yellow', 'red']); +export const stakeholderCategoryEnum = pgEnum('sh_category', ['manage_closely', 'keep_satisfied', 'keep_informed', 'monitor']); + +// Projects +export const projects = pgTable('projects', { + id: text('id').primaryKey(), + name: text('name').notNull(), + goal: text('goal'), + scopeIn: text('scope_in'), + scopeOut: text('scope_out'), + constraints: text('constraints'), + assumptions: text('assumptions'), + status: text('status').default('active'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Stakeholders +export const stakeholders = pgTable('stakeholders', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + role: text('role'), + organization: text('organization'), + contact: text('contact'), + power: integer('power').notNull().default(3), + interest: integer('interest').notNull().default(3), + category: stakeholderCategoryEnum('category').notNull(), + engagementStrategy: text('engagement_strategy'), + communicationMethod: text('communication_method'), + communicationFreq: text('communication_freq'), + notes: text('notes'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// WBS Nodes +export const wbsNodes = pgTable('wbs_nodes', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + parentId: text('parent_id'), + name: text('name').notNull(), + description: text('description'), + level: integer('level').notNull().default(0), + wbsCode: text('wbs_code'), + estimatedHours: integer('estimated_hours').default(0), + assignee: text('assignee'), + assigneeType: text('assignee_type').default('ai'), + status: taskStatusEnum('status').default('not_started'), + priority: text('priority').default('medium'), + dependencies: jsonb('dependencies').default([]), + deliverables: jsonb('deliverables').default([]), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Risks +export const risks = pgTable('risks', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + title: text('title').notNull(), + description: text('description'), + category: text('category'), + probability: integer('probability').notNull().default(1), + impact: integer('impact').notNull().default(1), + riskScore: integer('risk_score').notNull().default(1), + level: riskLevelEnum('level').notNull(), + strategy: riskStrategyEnum('strategy').default('mitigate'), + responsePlan: text('response_plan'), + triggerCondition: text('trigger_condition'), + owner: text('owner'), + status: riskStatusEnum('status').default('identified'), + dueDate: text('due_date'), + relatedTasks: jsonb('related_tasks').default([]), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Requirements +export const requirements = pgTable('requirements', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + title: text('title').notNull(), + description: text('description'), + priority: reqPriorityEnum('priority').notNull().default('should'), + userStory: jsonb('user_story'), + category: text('category').default('functional'), + status: reqStatusEnum('status').default('draft'), + conflicts: jsonb('conflicts').default([]), + dependencies: jsonb('dependencies').default([]), + estimatedHours: integer('estimated_hours'), + complexity: text('complexity'), + relatedTasks: jsonb('related_tasks').default([]), + relatedRisks: jsonb('related_risks').default([]), + source: text('source'), + requesterOpenId: text('requester_open_id'), + tags: jsonb('tags').default([]), + notes: text('notes'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Change Requests +export const changeRequests = pgTable('change_requests', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + title: text('title').notNull(), + description: text('description'), + type: text('type').notNull(), + status: changeStatusEnum('status').default('submitted'), + requester: text('requester'), + requesterOpenId: text('requester_open_id'), + impact: jsonb('impact'), + relatedWBSNodes: jsonb('related_wbs_nodes').default([]), + relatedRisks: jsonb('related_risks').default([]), + relatedRequirements: jsonb('related_requirements').default([]), + proposedSolution: text('proposed_solution'), + estimatedEffort: integer('estimated_effort'), + estimatedCost: integer('estimated_cost'), + approver: text('approver'), + approvedAt: timestamp('approved_at'), + rejectionReason: text('rejection_reason'), + implementationNotes: text('implementation_notes'), + implementedAt: timestamp('implemented_at'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Health Reports +export const healthReports = pgTable('health_reports', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + overall: healthStatusEnum('overall').notNull(), + overallScore: integer('overall_score').notNull(), + dimensions: jsonb('dimensions').notNull(), + summary: text('summary'), + risks: jsonb('risks').default([]), + recommendations: jsonb('recommendations').default([]), + periodFrom: timestamp('period_from'), + periodTo: timestamp('period_to'), + createdAt: timestamp('created_at').defaultNow(), +}); + +// Knowledge Entries +export const knowledgeEntries = pgTable('knowledge_entries', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + type: text('type').notNull(), + title: text('title').notNull(), + content: text('content').notNull(), + tags: jsonb('tags').default([]), + source: text('source').default('user_input'), + relatedPhase: text('related_phase'), + relatedTaskType: text('related_task_type'), + usefulness: integer('usefulness').default(50), + references: integer('references').default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +// Retrospectives +export const retrospectives = pgTable('retrospectives', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + wentWell: jsonb('went_well').default([]), + toImprove: jsonb('to_improve').default([]), + lessons: jsonb('lessons').default([]), + reusableAssets: jsonb('reusable_assets').default([]), + knowledgeEntryIds: jsonb('knowledge_entry_ids').default([]), + aiInsights: jsonb('ai_insights').default([]), + projectScore: integer('project_score'), + createdAt: timestamp('created_at').defaultNow(), +}); + +// Decision Records +export const decisionRecords = pgTable('decision_records', { + id: text('id').primaryKey(), + cardTitle: text('card_title'), + type: text('type'), + options: jsonb('options').default([]), + chosenOption: text('chosen_option'), + status: text('status').default('pending'), + respondedAt: timestamp('responded_at'), + createdAt: timestamp('created_at').defaultNow(), +}); + +// Model Configs +export const modelConfigs = pgTable('model_configs', { + id: text('id').primaryKey(), + provider: text('provider').notNull(), + displayName: text('display_name').notNull(), + capabilities: jsonb('capabilities').default([]), + maxTokens: integer('max_tokens').default(4096), + priceInput: text('price_input'), + priceOutput: text('price_output'), + avgLatencyMs: integer('avg_latency_ms').default(2000), + avgScore: integer('avg_score').default(70), + enabled: boolean('enabled').default(true), +}); + +// Model Call Records +export const modelCallRecords = pgTable('model_call_records', { + id: text('id').primaryKey(), + projectId: text('project_id'), + taskId: text('task_id'), + modelId: text('model_id').notNull(), + promptTokens: integer('prompt_tokens').default(0), + completionTokens: integer('completion_tokens').default(0), + totalTokens: integer('total_tokens').default(0), + cost: text('cost').default('0'), + latencyMs: integer('latency_ms').default(0), + score: integer('score'), + success: boolean('success').default(true), + errorMessage: text('error_message'), + createdAt: timestamp('created_at').defaultNow(), +}); + +// Execution Logs +export const executionLogs = pgTable('execution_logs', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + taskName: text('task_name'), + agentType: text('agent_type'), + model: text('model'), + input: text('input'), + output: text('output'), + score: integer('score'), + durationMs: integer('duration_ms'), + status: text('status').default('pending'), + createdAt: timestamp('created_at').defaultNow(), +}); diff --git a/src/server/dev.ts b/src/server/dev.ts index 5f97cca..ae6a577 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -371,3 +371,7 @@ const port = Number(process.env.PORT) || 3001; serve({ fetch: app.fetch, port }, () => { console.log(`🚀 FlowPilot API running on http://localhost:${port}`); }); + +// Database init (graceful fallback to memory mode) +import { initDB } from '../db'; +initDB().catch(err => console.warn('DB init warning:', err.message));