feat: database layer - PostgreSQL schema + memory fallback
Some checks failed
CI / lint-and-typecheck (push) Failing after 16m21s
CI / test (push) Has been skipped
CI / build (push) Has been skipped

- 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:
xiaohei
2026-04-12 19:05:03 +08:00
parent 2532cf4f4e
commit 15a70e4ecf
7 changed files with 477 additions and 1 deletions

10
drizzle.config.ts Normal file
View 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
View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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(),
});

View File

@@ -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));