feat: database layer - PostgreSQL schema + memory fallback
- 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
This commit is contained in:
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -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',
|
||||
},
|
||||
});
|
||||
140
package-lock.json
generated
140
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
26
src/db/index.ts
Normal file
26
src/db/index.ts
Normal file
@@ -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<typeof drizzle> | null = null;
|
||||
let useMemory = true;
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
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; }
|
||||
46
src/db/memory-store.ts
Normal file
46
src/db/memory-store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Generic in-memory store (fallback when no PostgreSQL)
|
||||
*/
|
||||
|
||||
type Store = Map<string, any>;
|
||||
const stores = new Map<string, Store>();
|
||||
|
||||
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>): 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;
|
||||
},
|
||||
};
|
||||
246
src/db/schema.ts
Normal file
246
src/db/schema.ts
Normal file
@@ -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(),
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user