HyperMind Lab M1 架构地基 Implementation Plan <二>

Task 4: SQLite 22 表全量建库(schema.sql + db.ts)

Files:

  • Create: server/db/schema.sql
  • Create: server/db/db.ts
  • Test: server/db/__tests__/schema.test.ts

Interfaces:

  • Consumes: Task 3 的 nowIso

  • Produces:

    • openDb(path?: string): Database(默认 data/hypermind.db,缺省建 data/ 目录;:memory: 用于测试)
    • getDb(): Database(进程级单例)
    • SCHEMA_VERSION = 1(写入 meta 表)
  • Step 1: 写失败测试

Create server/db/__tests__/schema.test.ts

ts 复制代码
import { describe, expect, it, afterEach } from 'vitest';
import { openDb } from '../db';

describe('schema', () => {
  const db = openDb(':memory:');
  afterEach(() => db.close());

  it('creates all 22 tables idempotently', () => {
    const tables = db
      .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
      .all()
      .map((r: { name: string }) => r.name);
    expect(tables).toHaveLength(22);
    for (const t of ['projects', 'nodes', 'hyphae', 'knots', 'eggs', 'wormholes', 'ethics_assessments',
      'ethics_audit_log', 'aesthetic_events', 'federation_records', 'space_objects', 'high_dim_reconstructions',
      'operations', 'events', 'value_snapshots', 'snapshots', 'artifacts', 'experiments', 'audit_log',
      'episodes', 'sprite_interactions', 'meta']) {
      expect(tables).toContain(t);
    }
  });

  it('re-running init is idempotent', () => {
    openDb(':memory:').close();
  });

  it('meta stores schema_version=1', () => {
    const row = db.prepare("SELECT value_json FROM meta WHERE key='schema_version'").get() as { value_json: string } | undefined;
    expect(row).toBeDefined();
    expect(JSON.parse(row!.value_json)).toBe(1);
  });

  it('events table rejects UPDATE and DELETE (append-only)', () => {
    db.prepare("INSERT INTO events (id, project_id, sequence, event_type, entity_type, payload_json, priority) VALUES ('evnt_t', 'proj_t', 1, 'node.created', 'node', '{}', 0)").run();
    expect(() =>
      db.prepare("UPDATE events SET event_type='hacked' WHERE id='evnt_t'").run(),
    ).toThrow();
    expect(() =>
      db.prepare("DELETE FROM events WHERE id='evnt_t'").run(),
    ).toThrow();
  });

  it('ethics_audit_log rejects UPDATE and DELETE', () => {
    db.prepare("INSERT INTO ethics_audit_log (id, project_id, timestamp, action_type, target_node_id, result) VALUES ('ethc_t', 'proj_t', '2026-01-01', 'human_review', 'node_t', 'passed')").run();
    expect(() => db.prepare("UPDATE ethics_audit_log SET result='blocked' WHERE id='ethc_t'").run()).toThrow();
    expect(() => db.prepare("DELETE FROM ethics_audit_log WHERE id='ethc_t'").run()).toThrow();
  });

  it('projects validation_mode is constrained', () => {
    expect(() =>
      db.prepare("INSERT INTO projects (id, name, root_seed_idea, validation_mode, value_weights_json) VALUES ('proj_x', 'x', 'idea', 'bogus', '{}')").run(),
    ).toThrow();
  });

  it('nodes cascade-delete with project', () => {
    db.prepare("INSERT INTO projects (id, name, root_seed_idea, validation_mode, value_weights_json) VALUES ('proj_c', 'c', 'idea', 'deductive', '{}')").run();
    db.prepare("INSERT INTO nodes (id, project_id, title, type) VALUES ('node_c', 'proj_c', 'n', 'seed')").run();
    db.prepare("DELETE FROM projects WHERE id='proj_c'").run();
    const n = db.prepare("SELECT id FROM nodes WHERE id='node_c'").get();
    expect(n).toBeUndefined();
  });
});
  • Step 2: 运行确认失败

Run: npx vitest run server/db/__tests__/schema.test.ts

Expected: FAIL(../db 不存在)。

  • Step 3: 实现 schema.sql

Create server/db/schema.sql(完整 22 表 DDL,幂等;JSON 嵌套列与数据要素最终版字段全量对齐):

sql 复制代码
-- HyperMind Lab schema v1 ------ 22 表全量建库(幂等)
-- 演进:meta.schema_version 控制增量 ALTER;Neo4j/Qdrant/Kafka 迁移映射见设计文档 §3.3

PRAGMA journal_mode = WAL;

CREATE TABLE IF NOT EXISTS projects (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  root_seed_idea TEXT NOT NULL,
  validation_mode TEXT NOT NULL CHECK (validation_mode IN ('deductive','simulation','hybrid','experimental')),
  value_weights_json TEXT NOT NULL DEFAULT '{}',
  privacy_mode INTEGER NOT NULL DEFAULT 0,
  paradigm_generation INTEGER NOT NULL DEFAULT 0,
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  deleted_at TEXT
);

CREATE TABLE IF NOT EXISTS nodes (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  parent_id TEXT,
  depth INTEGER NOT NULL DEFAULT 0,
  type TEXT NOT NULL DEFAULT 'derived' CHECK (type IN ('seed','derived','merged','external')),
  title TEXT NOT NULL,
  summary TEXT NOT NULL DEFAULT '',
  content_json TEXT NOT NULL DEFAULT '{}',          -- {fullText, propositions[], tags[], domainLabels[], references[]}
  domain TEXT,
  level INTEGER,
  dimension TEXT,
  cognitive_type TEXT NOT NULL DEFAULT 'normal' CHECK (cognitive_type IN ('normal','counterfactual','paradox','paradigm_shift','negative_space')),
  embedding BLOB,
  embedding_model TEXT,
  embedding_updated_at TEXT,
  cognitive_potential REAL,
  value_scores_json TEXT NOT NULL DEFAULT '{}',
  validation_json TEXT NOT NULL DEFAULT '{}',
  dual_use_risk_json TEXT,
  aesthetic_tremors_json TEXT NOT NULL DEFAULT '[]',
  incubation_state_json TEXT,
  innovation_depth TEXT CHECK (innovation_depth IN ('L1','L2','L3','L4')),
  paradigm_generation INTEGER NOT NULL DEFAULT 0,
  pareto_rank INTEGER,
  spatial_json TEXT NOT NULL DEFAULT '{}',
  source TEXT NOT NULL DEFAULT 'user' CHECK (source IN ('user','fission','fusion','expansion','external')),
  parent_operation_id TEXT,
  created_by TEXT,
  version INTEGER NOT NULL DEFAULT 1,
  last_activated_at TEXT,
  activation_count INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_nodes_project ON nodes(project_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id);
CREATE INDEX IF NOT EXISTS idx_nodes_cognitive ON nodes(project_id, cognitive_type);
CREATE INDEX IF NOT EXISTS idx_nodes_paradigm ON nodes(project_id, paradigm_generation);

CREATE TABLE IF NOT EXISTS hyphae (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  source_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
  target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('semantic_auto','user_manual','causal','analogical','contradiction','hierarchical','sequential','complementary')),
  strength REAL NOT NULL DEFAULT 0.5,
  semantic_similarity REAL NOT NULL DEFAULT 0,
  coactivation_count INTEGER NOT NULL DEFAULT 0,
  manual_reinforcement_count INTEGER NOT NULL DEFAULT 0,
  growth_history_json TEXT NOT NULL DEFAULT '{}',
  value_flow_json TEXT,
  visual_properties_json TEXT NOT NULL DEFAULT '{}',
  path_json TEXT,
  is_wormhole INTEGER NOT NULL DEFAULT 0,
  wormhole_properties_json TEXT,
  federation_properties_json TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_hyphae_project ON hyphae(project_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_hyphae_src ON hyphae(source_node_id);
CREATE INDEX IF NOT EXISTS idx_hyphae_tgt ON hyphae(target_node_id);
CREATE INDEX IF NOT EXISTS idx_hyphae_wormhole ON hyphae(project_id, is_wormhole);

CREATE TABLE IF NOT EXISTS knots (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  member_node_ids_json TEXT NOT NULL DEFAULT '[]',
  internal_hyphae_ids_json TEXT NOT NULL DEFAULT '[]',
  density REAL NOT NULL DEFAULT 0,
  status TEXT NOT NULL DEFAULT 'emerging' CHECK (status IN ('emerging','confirmed','dissolving','dissolved')),
  emergence_confidence REAL NOT NULL DEFAULT 0,
  auto_generated_summary TEXT,
  suggested_name_json TEXT NOT NULL DEFAULT '[]',
  user_given_name TEXT,
  confirmed_at TEXT,
  confirmed_by TEXT,
  dismissed_at TEXT,
  contour_json TEXT NOT NULL DEFAULT '{}',
  cluster_type TEXT NOT NULL DEFAULT 'normal' CHECK (cluster_type IN ('normal','new_paradigm')),
  paradigm_properties_json TEXT,
  topological_properties_json TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_knots_project ON knots(project_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_knots_status ON knots(project_id, status);
CREATE INDEX IF NOT EXISTS idx_knots_cluster ON knots(project_id, cluster_type);

CREATE TABLE IF NOT EXISTS eggs (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  status TEXT NOT NULL CHECK (status IN ('detected','incubating','hatched','failed','dismissed')),
  anomaly_source TEXT NOT NULL CHECK (anomaly_source IN ('isolation_forest','surprise_accumulation','semantic_blackhole','topological_cavity')),
  anomaly_nodes_json TEXT NOT NULL DEFAULT '[]',
  anomaly_metrics_json TEXT NOT NULL DEFAULT '{}',
  incubation_json TEXT,
  hatch_result_json TEXT,
  detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  last_user_action_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_eggs_project ON eggs(project_id, status);

CREATE TABLE IF NOT EXISTS wormholes (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  hypha_id TEXT NOT NULL,
  source_knot_id TEXT NOT NULL,
  target_knot_id TEXT NOT NULL,
  paradigm_generation_gap INTEGER NOT NULL,
  transformed_concepts_detail_json TEXT NOT NULL DEFAULT '[]',
  established_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  last_traversed_at TEXT,
  traversal_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_wormholes_project ON wormholes(project_id);

CREATE TABLE IF NOT EXISTS ethics_assessments (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL CHECK (entity_type IN ('dual_use','red_team')),
  target_node_id TEXT,
  target_achievement_id TEXT,
  score REAL,
  risk_categories_json TEXT NOT NULL DEFAULT '[]',
  assessment_detail TEXT,
  status TEXT CHECK (status IN ('running','completed','failed')),
  abuse_paths_found_json TEXT,
  assessed_by TEXT,
  assessed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_ethics_assess ON ethics_assessments(project_id, target_node_id);

CREATE TABLE IF NOT EXISTS ethics_audit_log (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  action_type TEXT NOT NULL CHECK (action_type IN ('input_screening','dual_use_assessment','red_team_simulation','human_review','human_override','ethics_waiver')),
  target_node_id TEXT,
  result TEXT NOT NULL CHECK (result IN ('passed','flagged','blocked','conditionally_approved')),
  detail_json TEXT,
  reviewer_id TEXT,
  digital_signature TEXT,
  prev_log_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_ethics_audit ON ethics_audit_log(project_id, timestamp);
-- 不可变性:只追加,篡改尝试由触发器拒绝
CREATE TRIGGER IF NOT EXISTS trg_ethics_audit_no_update BEFORE UPDATE ON ethics_audit_log
BEGIN SELECT RAISE(ABORT, 'ethics_audit_log is append-only'); END;
CREATE TRIGGER IF NOT EXISTS trg_ethics_audit_no_delete BEFORE DELETE ON ethics_audit_log
BEGIN SELECT RAISE(ABORT, 'ethics_audit_log is append-only'); END;

CREATE TABLE IF NOT EXISTS aesthetic_events (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL CHECK (entity_type IN ('tremor','resonance')),
  node_id TEXT NOT NULL,
  session_id TEXT,
  trigger_type TEXT CHECK (trigger_type IN ('long_gaze','repeated_zoom','rapid_screenshot','prolonged_highlight')),
  intensity REAL,
  context_json TEXT,
  structural_similarity REAL,
  user_notified INTEGER NOT NULL DEFAULT 0,
  user_engaged INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_aesthetic ON aesthetic_events(project_id, node_id);

CREATE TABLE IF NOT EXISTS federation_records (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL CHECK (entity_type IN ('summary','connection','consensus')),
  anonymous_id TEXT,
  simhash TEXT,
  abstract_summary TEXT,
  structural_fingerprint_json TEXT,
  peer_instance_id TEXT,
  connection_status TEXT CHECK (connection_status IN ('established','active','suspended')),
  consensus_strength REAL,
  access_count INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_federation ON federation_records(project_id, entity_type);

CREATE TABLE IF NOT EXISTS space_objects (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  object_type TEXT NOT NULL CHECK (object_type IN ('gravity_well','repulsion_wall','terrain')),
  position_json TEXT,
  radius REAL,
  strength REAL NOT NULL DEFAULT 0.5,
  active INTEGER NOT NULL DEFAULT 1,
  points_json TEXT,
  boundary_points_json TEXT,
  terrain_type TEXT CHECK (terrain_type IN ('highland','lowland')),
  created_by TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_space ON space_objects(project_id, object_type, active);

CREATE TABLE IF NOT EXISTS high_dim_reconstructions (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  source_perspective_node_ids_json TEXT NOT NULL DEFAULT '[]',
  prism_node_id TEXT,
  reconstruction_status TEXT NOT NULL DEFAULT 'processing' CHECK (reconstruction_status IN ('processing','completed','failed')),
  transformation_rules_json TEXT NOT NULL DEFAULT '[]',
  emergent_properties_json TEXT NOT NULL DEFAULT '[]',
  independent_predictions_json TEXT NOT NULL DEFAULT '[]',
  reconstructed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_hdrc ON high_dim_reconstructions(project_id, reconstruction_status);

CREATE TABLE IF NOT EXISTS operations (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  operation_type TEXT NOT NULL,
  operator_id TEXT,
  input_ids_json TEXT NOT NULL DEFAULT '[]',
  output_ids_json TEXT NOT NULL DEFAULT '[]',
  params_json TEXT NOT NULL DEFAULT '{}',
  duration_ms INTEGER,
  status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('running','done','failed','cancelled')),
  error TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_operations ON operations(project_id, created_at);

CREATE TABLE IF NOT EXISTS events (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  sequence INTEGER NOT NULL UNIQUE,
  event_type TEXT NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id TEXT,
  payload_json TEXT NOT NULL DEFAULT '{}',
  priority INTEGER NOT NULL DEFAULT 2,
  occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_events_project_seq ON events(project_id, sequence);
CREATE INDEX IF NOT EXISTS idx_events_entity ON events(entity_type, entity_id);
-- 事件溯源追加式
CREATE TRIGGER IF NOT EXISTS trg_events_no_update BEFORE UPDATE ON events
BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER IF NOT EXISTS trg_events_no_delete BEFORE DELETE ON events
BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;

CREATE TABLE IF NOT EXISTS value_snapshots (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL CHECK (entity_type IN ('node','project','knot')),
  entity_id TEXT NOT NULL,
  survival REAL NOT NULL DEFAULT 0,
  cognitive REAL NOT NULL DEFAULT 0,
  aesthetic REAL NOT NULL DEFAULT 0,
  ethical REAL NOT NULL DEFAULT 0,
  transcendent REAL NOT NULL DEFAULT 0,
  pareto_rank INTEGER,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_valsnap ON value_snapshots(project_id, entity_id, created_at);

CREATE TABLE IF NOT EXISTS snapshots (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  snapshot_type TEXT NOT NULL CHECK (snapshot_type IN ('manual','auto','milestone')),
  data_json TEXT NOT NULL DEFAULT '{}',
  reason TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_snapshots ON snapshots(project_id, snapshot_type, created_at);

CREATE TABLE IF NOT EXISTS artifacts (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  artifact_type TEXT NOT NULL,
  title TEXT NOT NULL,
  content_json TEXT NOT NULL DEFAULT '{}',
  related_knot_ids_json TEXT NOT NULL DEFAULT '[]',
  status TEXT NOT NULL DEFAULT 'draft',
  exported_at TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);

CREATE TABLE IF NOT EXISTS experiments (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  experiment_type TEXT NOT NULL,
  config_json TEXT NOT NULL DEFAULT '{}',
  result_json TEXT,
  status TEXT NOT NULL DEFAULT 'planned',
  logs_json TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);

CREATE TABLE IF NOT EXISTS audit_log (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  action_type TEXT NOT NULL,
  target_type TEXT,
  target_id TEXT,
  detail_json TEXT,
  operator TEXT,
  occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_audit ON audit_log(project_id, occurred_at);

CREATE TABLE IF NOT EXISTS episodes (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  episode_type TEXT NOT NULL,
  summary TEXT NOT NULL,
  node_ids_json TEXT NOT NULL DEFAULT '[]',
  hyphae_ids_json TEXT NOT NULL DEFAULT '[]',
  context_json TEXT,
  archived_at TEXT
);

CREATE TABLE IF NOT EXISTS sprite_interactions (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  sprite_id TEXT NOT NULL DEFAULT 'default',
  interaction_type TEXT NOT NULL,
  context_json TEXT,
  response_json TEXT,
  satisfaction_rating INTEGER,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);

CREATE TABLE IF NOT EXISTS meta (
  key TEXT PRIMARY KEY,
  value_json TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
  • Step 4: 实现 db.ts

Create server/db/db.ts

ts 复制代码
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';

export const SCHEMA_VERSION = 1;
const DEFAULT_DB_PATH = path.join(process.cwd(), 'data', 'hypermind.db');

let singleton: Database.Database | null = null;

export function openDb(dbPath: string = DEFAULT_DB_PATH): Database.Database {
  if (dbPath !== ':memory:' && !fs.existsSync(path.dirname(dbPath))) {
    fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  }
  const db = new Database(dbPath);
  db.pragma('journal_mode = WAL');
  db.pragma('foreign_keys = ON');
  const sql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
  db.exec(sql);
  db.prepare(
    "INSERT INTO meta (key, value_json) VALUES ('schema_version', ?) ON CONFLICT(key) DO NOTHING",
  ).run(JSON.stringify(SCHEMA_VERSION));
  return db;
}

export function getDb(): Database.Database {
  if (!singleton) {
    singleton = openDb();
  }
  return singleton;
}

export function resetDbForTests(): void {
  singleton?.close();
  singleton = null;
}

注:__dirname 在 tsx/ESM 下不可用,改用 fileURLToPath(new URL('.', import.meta.url))

ts 复制代码
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));

db.ts 内使用后一种方式定位 schema.sql。)

  • Step 5: 运行测试验证

Run: npx vitest run server/db/__tests__/schema.test.ts

Expected: PASS(6 个用例)。

  • Step 6: Commit
bash 复制代码
git add server/db/ data/.gitkeep
git commit -m "feat(db): 22-table schema, append-only triggers, WAL init"

Task 5: Repositories(projects / graph / history / meta)

Files:

  • Create: server/db/repositories/rowMapper.ts(JSON 列 ↔ 实体映射 helper)
  • Create: server/db/repositories/projectsRepo.ts
  • Create: server/db/repositories/graphRepo.ts
  • Create: server/db/repositories/historyRepo.ts
  • Create: server/db/repositories/metaRepo.ts
  • Create: server/db/repositories/index.ts
  • Test: server/db/__tests__/repos.test.ts

Interfaces:

  • Consumes: Task 2 类型(ConceptNode/HyphaConnection/HyphalKnot/ProjectRecord/EventRecord/OperationRecord/ValueSnapshot)、Task 3 prefixedId/nowIso、Task 4 openDb

  • Produces(签名契约,后续路由与前端命令依赖):

    • projectsRepo.list({page,pageSize,offset}): {rows: ProjectRecord[], total: number}
    • projectsRepo.create(input: {name, rootSeedIdea, validationMode, valueWeights, privacyMode, description?}): ProjectRecord
    • projectsRepo.getById(id): ProjectRecord | undefined(含 deleted)
    • projectsRepo.softDelete(id): void / projectsRepo.restore(id): void / projectsRepo.hardDelete(id): void
    • graphRepo.createNode(projectId, data: Partial<ConceptNode>): ConceptNode(自动 id/depth/createdAt,默认值填充)
    • graphRepo.listNodes(projectId, {includeDeleted?: boolean}): ConceptNode[]
    • graphRepo.getNode(projectId, nodeId): ConceptNode | undefined
    • graphRepo.updateNode(projectId, nodeId, patch: Partial<ConceptNode>): ConceptNode(version+1、updatedAt 刷新)
    • graphRepo.softDeleteNode(projectId, nodeId): void / graphRepo.restoreNode(projectId, nodeId): void / graphRepo.hardDeleteNode(projectId, nodeId): void(级联软删其 hyphae)
    • graphRepo.createHypha(projectId, data): HyphaConnection(source/target 必须同项目且存在)
    • graphRepo.listHyphae(projectId, {includeDeleted?}): HyphaConnection[]
    • graphRepo.softDeleteHypha / restoreHypha / hardDeleteHypha
    • graphRepo.createKnot(projectId, data): HyphalKnot(memberNodeIds ≥3 校验)
    • graphRepo.listKnots(projectId): HyphalKnot[] / confirmKnot(status→confirmed + confirmedAt)/ dissolveKnot
    • historyRepo.recordOperation(projectId, op: Partial<OperationRecord>): OperationRecord
    • historyRepo.appendEvent(projectId, ev: {eventType, entityType, entityId?, payload, priority}): EventRecord(自动 sequence = MAX+1)
    • historyRepo.listEvents(projectId, {sinceSeq?, types?, page, pageSize, offset}): {rows, total}
    • historyRepo.createSnapshot(projectId, {snapshotType, data, reason?}): Snapshot
    • historyRepo.listSnapshots(projectId, snapshotType?): Snapshot[]
    • metaRepo.getMeta(db, key): unknown | undefined / setMeta(db, key, value): void
  • Step 1: 写失败测试

Create server/db/__tests__/repos.test.ts

ts 复制代码
import { describe, expect, it, beforeEach } from 'vitest';
import { openDb, resetDbForTests } from '../db';
import { projectsRepo, graphRepo, historyRepo } from '../repositories';

let db: ReturnType<typeof openDb>;

beforeEach(() => {
  resetDbForTests();
  db = openDb(':memory:');
});

describe('repos', () => {
  it('projects: create/list/soft-delete/restore', () => {
    const p = projectsRepo.create({ name: '思维实验室', rootSeedIdea: '量子认知', validationMode: 'hybrid', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    expect(p.id).toMatch(/^proj_/);
    expect(p.paradigmGeneration).toBe(0);
    const listed = projectsRepo.list({ page: 1, pageSize: 50, offset: 0 });
    expect(listed.total).toBe(1);
    projectsRepo.softDelete(p.id);
    expect(projectsRepo.getById(p.id)?.deletedAt).toBeTruthy();
    expect(projectsRepo.list({ page: 1, pageSize: 50, offset: 0 }).total).toBe(0);
    projectsRepo.restore(p.id);
    expect(projectsRepo.list({ page: 1, pageSize: 50, offset: 0 }).total).toBe(1);
  });

  it('graph: node create/update/soft-delete restores via graveyard flow', () => {
    const p = projectsRepo.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    const n = graphRepo.createNode(p.id, { title: '种子', type: 'seed' });
    expect(n.id).toMatch(/^node_/);
    expect(n.depth).toBe(0);
    expect(n.cognitiveType).toBe('normal');
    const updated = graphRepo.updateNode(p.id, n.id, { title: '种子 v2' });
    expect(updated.version).toBe(2);
    expect(updated.title).toBe('种子 v2');
    graphRepo.softDeleteNode(p.id, n.id);
    expect(graphRepo.listNodes(p.id).length).toBe(0);
    expect(graphRepo.listNodes(p.id, { includeDeleted: true }).length).toBe(1);
    graphRepo.restoreNode(p.id, n.id);
    expect(graphRepo.listNodes(p.id).length).toBe(1);
  });

  it('graph: hypha requires existing same-project nodes', () => {
    const p = projectsRepo.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    const a = graphRepo.createNode(p.id, { title: 'A', type: 'derived' });
    const b = graphRepo.createNode(p.id, { title: 'B', type: 'derived' });
    const h = graphRepo.createHypha(p.id, { sourceNodeId: a.id, targetNodeId: b.id, type: 'analogical' });
    expect(h.id).toMatch(/^hyph_/);
    expect(() => graphRepo.createHypha(p.id, { sourceNodeId: 'node_none', targetNodeId: b.id, type: 'causal' })).toThrow();
  });

  it('graph: knot requires >=3 members', () => {
    const p = projectsRepo.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    const nodes = [1, 2, 3].map((i) => graphRepo.createNode(p.id, { title: `N${i}`, type: 'derived' }));
    expect(() => graphRepo.createKnot(p.id, { memberNodeIds: nodes.slice(0, 2).map((n) => n.id) })).toThrow();
    const k = graphRepo.createKnot(p.id, { memberNodeIds: nodes.map((n) => n.id) });
    expect(k.status).toBe('emerging');
    const confirmed = graphRepo.confirmKnot(p.id, k.id, 'user1');
    expect(confirmed.status).toBe('confirmed');
    expect(confirmed.confirmedAt).toBeTruthy();
  });

  it('history: events sequence monotonic, operation recorded', () => {
    const p = projectsRepo.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    const e1 = historyRepo.appendEvent(p.id, { eventType: 'node.created', entityType: 'node', entityId: 'node_x', payload: { commandType: 'node.create' }, priority: 0 });
    const e2 = historyRepo.appendEvent(p.id, { eventType: 'hypha.created', entityType: 'hypha', payload: {}, priority: 1 });
    expect(e2.sequence).toBe(e1.sequence + 1);
    const page = historyRepo.listEvents(p.id, { page: 1, pageSize: 50, offset: 0 });
    expect(page.total).toBe(2);
    const inc = historyRepo.listEvents(p.id, { sinceSeq: e1.sequence, page: 1, pageSize: 50, offset: 0 });
    expect(inc.total).toBe(1);
    const op = historyRepo.recordOperation(p.id, { operationType: 'fission', inputIds: ['node_x'], outputIds: ['node_y'], params: { operator: 'ontology_split' }, status: 'done' });
    expect(op.id).toMatch(/^oper_/);
  });

  it('history: snapshot lifecycle', () => {
    const p = projectsRepo.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    historyRepo.createSnapshot(p.id, { snapshotType: 'auto', data: { nodes: [] }, reason: '50-ops' });
    const snaps = historyRepo.listSnapshots(p.id, 'auto');
    expect(snaps).toHaveLength(1);
    expect(snaps[0].snapshotType).toBe('auto');
  });
});
  • Step 2: 运行确认失败

Run: npx vitest run server/db/__tests__/repos.test.ts

Expected: FAIL(../repositories 不存在)。

  • Step 3: 实现 rowMapper

Create server/db/repositories/rowMapper.ts

ts 复制代码
/** JSON 列 ↔ 实体字段映射:数据库 snake_case ↔ 实体 camelCase */
export function parseJson<T>(value: string | null | undefined, fallback: T): T {
  if (value == null || value === '') return fallback;
  try { return JSON.parse(value) as T; } catch { return fallback; }
}

export function toJson(value: unknown): string {
  return JSON.stringify(value ?? null);
}

export function rowToProject(row: Record<string, unknown>) {
  return {
    id: row.id as string,
    name: row.name as string,
    description: (row.description as string | null) ?? undefined,
    rootSeedIdea: row.root_seed_idea as string,
    validationMode: row.validation_mode as never,
    valueWeights: parseJson(row.value_weights_json as string, { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }),
    privacyMode: Boolean(row.privacy_mode),
    paradigmGeneration: row.paradigm_generation as number,
    status: row.status as 'active' | 'archived',
    createdAt: row.created_at as string,
    updatedAt: row.updated_at as string,
    deletedAt: (row.deleted_at as string | null) ?? undefined,
  };
}

export function rowToNode(row: Record<string, unknown>) {
  return {
    id: row.id as string,
    projectId: row.project_id as string,
    parentId: (row.parent_id as string | null) ?? undefined,
    depth: row.depth as number,
    type: row.type as never,
    title: row.title as string,
    summary: (row.summary as string | null) ?? '',
    content: parseJson(row.content_json as string, { fullText: '', propositions: [], tags: [], domainLabels: [], references: [] }),
    domain: (row.domain as string | null) ?? undefined,
    level: (row.level as number | null) ?? undefined,
    dimension: (row.dimension as string | null) ?? undefined,
    cognitiveType: row.cognitive_type as never,
    cognitivePotential: (row.cognitive_potential as number | null) ?? undefined,
    valueScores: parseJson(row.value_scores_json as string, { survival: 0, cognitive: 0, aesthetic: 0, ethical: 0, transcendent: 0 }),
    validation: parseJson(row.validation_json as string, { mode: 'deductive', status: 'unverified', evidence: [] }),
    dualUseRisk: row.dual_use_risk_json ? parseJson(row.dual_use_risk_json as string, null) : null,
    aestheticTremors: parseJson(row.aesthetic_tremors_json as string, []),
    incubationState: row.incubation_state_json ? parseJson(row.incubation_state_json as string, null) : null,
    innovationDepth: (row.innovation_depth as never) ?? undefined,
    paradigmGeneration: row.paradigm_generation as number,
    paretoRank: (row.pareto_rank as number | null) ?? undefined,
    spatial: parseJson(row.spatial_json as string, { position: [0, 0, 0], radius: 1, color: '#888888' }),
    source: row.source as never,
    parentOperationId: (row.parent_operation_id as string | null) ?? undefined,
    version: row.version as number,
    lastActivatedAt: (row.last_activated_at as string | null) ?? undefined,
    activationCount: row.activation_count as number,
    createdAt: row.created_at as string,
    updatedAt: row.updated_at as string,
    deletedAt: (row.deleted_at as string | null) ?? undefined,
  };
}

export function rowToHypha(row: Record<string, unknown>) {
  return {
    id: row.id as string,
    projectId: row.project_id as string,
    sourceNodeId: row.source_node_id as string,
    targetNodeId: row.target_node_id as string,
    type: row.type as never,
    strength: row.strength as number,
    semanticSimilarity: row.semantic_similarity as number,
    coactivationCount: row.coactivation_count as number,
    manualReinforcementCount: row.manual_reinforcement_count as number,
    growthHistory: parseJson(row.growth_history_json as string, { emergedAt: '', emergedBy: 'system' }),
    valueFlow: row.value_flow_json ? parseJson(row.value_flow_json as string, null) : undefined,
    visualProperties: parseJson(row.visual_properties_json as string, { thickness: 1, opacity: 0.5, color: '#888888', dashPattern: 'solid', flowParticleDensity: 0 }),
    path: row.path_json ? parseJson(row.path_json as string, []) : undefined,
    isWormhole: Boolean(row.is_wormhole),
    wormholeProperties: row.wormhole_properties_json ? parseJson(row.wormhole_properties_json as string, null) : null,
    federationProperties: row.federation_properties_json ? parseJson(row.federation_properties_json as string, null) : null,
    createdAt: row.created_at as string,
    updatedAt: row.updated_at as string,
    deletedAt: (row.deleted_at as string | null) ?? undefined,
  };
}

export function rowToKnot(row: Record<string, unknown>) {
  return {
    id: row.id as string,
    projectId: row.project_id as string,
    memberNodeIds: parseJson(row.member_node_ids_json as string, []),
    internalHyphaeIds: parseJson(row.internal_hyphae_ids_json as string, []),
    density: row.density as number,
    status: row.status as never,
    emergenceConfidence: row.emergence_confidence as number,
    autoGeneratedSummary: (row.auto_generated_summary as string | null) ?? undefined,
    suggestedName: parseJson(row.suggested_name_json as string, []),
    userGivenName: (row.user_given_name as string | null) ?? undefined,
    confirmedAt: (row.confirmed_at as string | null) ?? undefined,
    dismissedAt: (row.dismissed_at as string | null) ?? undefined,
    contour: parseJson(row.contour_json as string, { center: [0, 0, 0], boundingRadius: 0, controlPoints: [] }),
    clusterType: row.cluster_type as never,
    paradigmProperties: row.paradigm_properties_json ? parseJson(row.paradigm_properties_json as string, null) : null,
    topologicalProperties: row.topological_properties_json ? parseJson(row.topological_properties_json as string, null) : null,
    createdAt: row.created_at as string,
    updatedAt: row.updated_at as string,
    deletedAt: (row.deleted_at as string | null) ?? undefined,
  };
}

export function rowToEvent(row: Record<string, unknown>) {
  return {
    id: row.id as string,
    projectId: row.project_id as string,
    sequence: row.sequence as number,
    eventType: row.event_type as string,
    entityType: row.entity_type as never,
    entityId: (row.entity_id as string | null) ?? undefined,
    payload: parseJson(row.payload_json as string, {}),
    priority: row.priority as number,
    occurredAt: row.occurred_at as string,
  };
}
  • Step 4: 实现四个 repo

Create server/db/repositories/projectsRepo.ts

ts 复制代码
import type Database from 'better-sqlite3';
import { prefixedId, nowIso } from '../../../src/lib/id';
import { toJson, rowToProject } from './rowMapper';
import { apiError } from '../../lib/errors';
import type { ProjectRecord, ValueScores, ValidationMode } from '../../../src/types';

export interface CreateProjectInput {
  name: string;
  rootSeedIdea: string;
  validationMode: ValidationMode;
  valueWeights: ValueScores;
  privacyMode: boolean;
  description?: string;
}

export function projectsRepo(db: Database.Database) {
  const table = 'projects';

  return {
    list({ page, pageSize, offset }: { page: number; pageSize: number; offset: number }) {
      const total = (db.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE deleted_at IS NULL`).get() as { c: number }).c;
      const rows = db.prepare(`SELECT * FROM ${table} WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(pageSize, offset);
      return { rows: rows.map(rowToProject), total };
    },

    create(input: CreateProjectInput): ProjectRecord {
      const id = prefixedId('proj_');
      const now = nowIso();
      const stmt = db.prepare(`
        INSERT INTO projects (id, name, description, root_seed_idea, validation_mode, value_weights_json, privacy_mode, created_at, updated_at)
        VALUES (@id, @name, @description, @rootSeedIdea, @validationMode, @valueWeights, @privacyMode, @now, @now)
      `);
      stmt.run({ id, name: input.name, description: input.description ?? null, rootSeedIdea: input.rootSeedIdea, validationMode: input.validationMode, valueWeights: toJson(input.valueWeights), privacyMode: input.privacyMode ? 1 : 0, now });
      return this.getById(id)!;
    },

    getById(id: string): ProjectRecord | undefined {
      const row = db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id);
      return row ? rowToProject(row as Record<string, unknown>) : undefined;
    },

    update(id: string, patch: Partial<Pick<ProjectRecord, 'name' | 'description' | 'status'>>): ProjectRecord {
      const cur = this.getById(id);
      if (!cur) throw apiError('ERR_NOT_FOUND', `project ${id} not found`);
      const next = { ...cur, ...patch, updatedAt: nowIso() };
      db.prepare(`UPDATE ${table} SET name=@name, description=@description, status=@status, updated_at=@updatedAt WHERE id=@id`)
        .run({ id, name: next.name, description: next.description ?? null, status: next.status, updatedAt: next.updatedAt });
      return this.getById(id)!;
    },

    softDelete(id: string): void {
      db.prepare(`UPDATE ${table} SET deleted_at=@t, updated_at=@t WHERE id=@id`).run({ id, t: nowIso() });
    },
    restore(id: string): void {
      db.prepare(`UPDATE ${table} SET deleted_at=NULL, updated_at=@t WHERE id=@id`).run({ id, t: nowIso() });
    },
    hardDelete(id: string): void {
      db.prepare(`DELETE FROM ${table} WHERE id=?`).run(id);
    },
  };
}

Create server/db/repositories/graphRepo.ts(核心节点/菌丝/菌丝结,含校验与级联软删):

ts 复制代码
import type Database from 'better-sqlite3';
import { prefixedId, nowIso } from '../../../src/lib/id';
import { toJson, rowToNode, rowToHypha, rowToKnot } from './rowMapper';
import { apiError } from '../../lib/errors';
import type { ConceptNode, HyphaConnection, HyphalKnot, CognitiveType, NodeType, HyphaType } from '../../../src/types';

const DEFAULT_VALUE_SCORES = { survival: 0, cognitive: 0, aesthetic: 0, ethical: 0, transcendent: 0 };

export function graphRepo(db: Database.Database) {
  const requireProject = (projectId: string) => {
    const ok = db.prepare('SELECT 1 FROM projects WHERE id=? AND deleted_at IS NULL').get(projectId);
    if (!ok) throw apiError('ERR_NOT_FOUND', `project ${projectId} not found`);
  };
  const requireNode = (projectId: string, nodeId: string) => {
    const ok = db.prepare('SELECT 1 FROM nodes WHERE id=? AND project_id=? AND deleted_at IS NULL').get(nodeId, projectId);
    if (!ok) throw apiError('ERR_NOT_FOUND', `node ${nodeId} not found in project`);
  };

  return {
    createNode(projectId: string, data: Partial<ConceptNode>): ConceptNode {
      requireProject(projectId);
      const now = nowIso();
      const id = data.id ?? prefixedId('node_');
      const type: NodeType = data.type ?? 'derived';
      const node: ConceptNode = {
        id,
        projectId,
        type,
        title: data.title ?? '未命名节点',
        summary: data.summary ?? '',
        content: { fullText: '', propositions: [], tags: [], domainLabels: [], references: [], ...data.content },
        domain: data.domain,
        level: data.level,
        dimension: data.dimension,
        cognitiveType: data.cognitiveType ?? 'normal' as CognitiveType,
        valueScores: data.valueScores ?? DEFAULT_VALUE_SCORES,
        validation: data.validation ?? { mode: 'deductive', status: 'unverified', evidence: [] },
        dualUseRisk: data.dualUseRisk ?? null,
        aestheticTremors: data.aestheticTremors ?? [],
        incubationState: data.incubationState ?? null,
        innovationDepth: data.innovationDepth,
        paradigmGeneration: data.paradigmGeneration ?? 0,
        paretoRank: data.paretoRank,
        spatial: data.spatial ?? { position: [0, 0, 0], radius: 1, color: '#888888' },
        metadata: { createdAt: now, updatedAt: now, activationCount: 0, source: data.source ?? 'user', version: 1, versionHistory: [], ...data.metadata },
        parentId: data.parentId,
        depth: data.depth ?? 0,
        deletedAt: null,
      };
      db.prepare(`
        INSERT INTO nodes (id, project_id, parent_id, depth, type, title, summary, content_json, domain, level, dimension,
          cognitive_type, cognitive_potential, value_scores_json, validation_json, dual_use_risk_json, aesthetic_tremors_json,
          incubation_state_json, innovation_depth, paradigm_generation, pareto_rank, spatial_json, source, parent_operation_id, created_at, updated_at)
        VALUES (@id,@projectId,@parentId,@depth,@type,@title,@summary,@content,@domain,@level,@dimension,
          @cognitiveType,@cognitivePotential,@valueScores,@validation,@dualUseRisk,@aestheticTremors,
          @incubationState,@innovationDepth,@paradigmGeneration,@paretoRank,@spatial,@source,@parentOperationId,@now,@now)
      `).run({
        id, projectId, parentId: node.parentId ?? null, depth: node.depth, type,
        title: node.title, summary: node.summary, content: toJson(node.content), domain: node.domain ?? null,
        level: node.level ?? null, dimension: node.dimension ?? null, cognitiveType: node.cognitiveType,
        cognitivePotential: node.cognitivePotential ?? null, valueScores: toJson(node.valueScores),
        validation: toJson(node.validation), dualUseRisk: node.dualUseRisk ? toJson(node.dualUseRisk) : null,
        aestheticTremors: toJson(node.aestheticTremors), incubationState: node.incubationState ? toJson(node.incubationState) : null,
        innovationDepth: node.innovationDepth ?? null, paradigmGeneration: node.paradigmGeneration,
        paretoRank: node.paretoRank ?? null, spatial: toJson(node.spatial), source: node.source,
        parentOperationId: node.parentOperationId ?? null, now,
      });
      return this.getNode(projectId, id)!;
    },

    listNodes(projectId: string, opts: { includeDeleted?: boolean } = {}): ConceptNode[] {
      const where = opts.includeDeleted ? 'project_id=?' : 'project_id=? AND deleted_at IS NULL';
      const rows = db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at`).all(projectId);
      return rows.map(rowToNode);
    },

    getNode(projectId: string, nodeId: string): ConceptNode | undefined {
      const row = db.prepare('SELECT * FROM nodes WHERE id=? AND project_id=?').get(nodeId, projectId);
      return row ? rowToNode(row as Record<string, unknown>) : undefined;
    },

    updateNode(projectId: string, nodeId: string, patch: Partial<ConceptNode>): ConceptNode {
      const cur = this.getNode(projectId, nodeId);
      if (!cur) throw apiError('ERR_NOT_FOUND', `node ${nodeId} not found`);
      const next: ConceptNode = { ...cur, ...patch, metadata: { ...cur.metadata, ...patch.metadata, version: cur.metadata.version + 1, updatedAt: nowIso() } };
      // 注意:createNode 的 INSERT 复用完整字段集;这里为控制篇幅直接全列 UPDATE:
      db.prepare(`
        UPDATE nodes SET title=@title, summary=@summary, content_json=@content, domain=@domain, level=@level,
          dimension=@dimension, cognitive_type=@cognitiveType, cognitive_potential=@cognitivePotential,
          value_scores_json=@valueScores, validation_json=@validation, dual_use_risk_json=@dualUseRisk,
          aesthetic_tremors_json=@aestheticTremors, incubation_state_json=@incubationState,
          innovation_depth=@innovationDepth, paradigm_generation=@paradigmGeneration, pareto_rank=@paretoRank,
          spatial_json=@spatial, parent_id=@parentId, version=@version, updated_at=@now
        WHERE id=@id AND project_id=@projectId
      `).run({
        id: nodeId, projectId, title: next.title, summary: next.summary, content: toJson(next.content),
        domain: next.domain ?? null, level: next.level ?? null, dimension: next.dimension ?? null,
        cognitiveType: next.cognitiveType, cognitivePotential: next.cognitivePotential ?? null,
        valueScores: toJson(next.valueScores), validation: toJson(next.validation),
        dualUseRisk: next.dualUseRisk ? toJson(next.dualUseRisk) : null,
        aestheticTremors: toJson(next.aestheticTremors),
        incubationState: next.incubationState ? toJson(next.incubationState) : null,
        innovationDepth: next.innovationDepth ?? null, paradigmGeneration: next.paradigmGeneration,
        paretoRank: next.paretoRank ?? null, spatial: toJson(next.spatial), parentId: next.parentId ?? null,
        version: next.metadata.version, now: next.metadata.updatedAt,
      });
      return this.getNode(projectId, nodeId)!;
    },

    softDeleteNode(projectId: string, nodeId: string): void {
      requireNode(projectId, nodeId);
      const t = nowIso();
      db.prepare('UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=?').run(t, t, nodeId);
      // 级联软删关联菌丝
      db.prepare('UPDATE hyphae SET deleted_at=?, updated_at=? WHERE (source_node_id=? OR target_node_id=?) AND deleted_at IS NULL').run(t, t, nodeId, nodeId);
    },
    restoreNode(projectId: string, nodeId: string): void {
      db.prepare('UPDATE nodes SET deleted_at=NULL, updated_at=? WHERE id=?').run(nowIso(), nodeId);
      db.prepare('UPDATE hyphae SET deleted_at=NULL, updated_at=? WHERE (source_node_id=? OR target_node_id=?) AND deleted_at IS NOT NULL').run(nowIso(), nodeId, nodeId);
    },
    hardDeleteNode(projectId: string, nodeId: string): void {
      db.prepare('DELETE FROM hyphae WHERE source_node_id=? OR target_node_id=?').run(nodeId, nodeId);
      db.prepare('DELETE FROM nodes WHERE id=?').run(nodeId);
    },

    createHypha(projectId: string, data: Partial<HyphaConnection>): HyphaConnection {
      requireProject(projectId);
      requireNode(projectId, data.sourceNodeId!);
      requireNode(projectId, data.targetNodeId!);
      if (data.sourceNodeId === data.targetNodeId) throw apiError('ERR_VALIDATION', 'self-loop hypha is not allowed');
      const now = nowIso();
      const id = prefixedId('hyph_');
      const type: HyphaType = data.type ?? 'user_manual';
      const hypha: HyphaConnection = {
        id, projectId, sourceNodeId: data.sourceNodeId!, targetNodeId: data.targetNodeId!, type,
        strength: data.strength ?? 0.5, semanticSimilarity: data.semanticSimilarity ?? 0,
        coactivationCount: 0, manualReinforcementCount: 0,
        growthHistory: { emergedAt: now, emergedBy: 'user', ...data.growthHistory },
        valueFlow: data.valueFlow, visualProperties: data.visualProperties ?? { thickness: 1, opacity: 0.4, color: '#888888', dashPattern: 'solid', flowParticleDensity: 0 },
        path: data.path, isWormhole: false, wormholeProperties: null, federationProperties: null,
        createdAt: now, updatedAt: now, deletedAt: null,
      };
      db.prepare(`
        INSERT INTO hyphae (id, project_id, source_node_id, target_node_id, type, strength, semantic_similarity,
          growth_history_json, value_flow_json, visual_properties_json, path_json, created_at, updated_at)
        VALUES (@id,@projectId,@sourceNodeId,@targetNodeId,@type,@strength,@semanticSimilarity,
          @growthHistory,@valueFlow,@visualProperties,@path,@now,@now)
      `).run({
        id, projectId, sourceNodeId: hypha.sourceNodeId, targetNodeId: hypha.targetNodeId, type,
        strength: hypha.strength, semanticSimilarity: hypha.semanticSimilarity,
        growthHistory: toJson(hypha.growthHistory), valueFlow: hypha.valueFlow ? toJson(hypha.valueFlow) : null,
        visualProperties: toJson(hypha.visualProperties), path: hypha.path ? toJson(hypha.path) : null, now,
      });
      return this.listHyphae(projectId).find((h) => h.id === id)!;
    },

    listHyphae(projectId: string, opts: { includeDeleted?: boolean } = {}): HyphaConnection[] {
      const where = opts.includeDeleted ? 'project_id=?' : 'project_id=? AND deleted_at IS NULL';
      const rows = db.prepare(`SELECT * FROM hyphae WHERE ${where} ORDER BY created_at`).all(projectId);
      return rows.map(rowToHypha);
    },

    softDeleteHypha(projectId: string, hyphaId: string): void {
      const t = nowIso();
      db.prepare('UPDATE hyphae SET deleted_at=?, updated_at=? WHERE id=? AND project_id=?').run(t, t, hyphaId, projectId);
    },
    restoreHypha(projectId: string, hyphaId: string): void {
      db.prepare('UPDATE hyphae SET deleted_at=NULL, updated_at=? WHERE id=? AND project_id=?').run(nowIso(), hyphaId, projectId);
    },
    hardDeleteHypha(projectId: string, hyphaId: string): void {
      db.prepare('DELETE FROM hyphae WHERE id=? AND project_id=?').run(hyphaId, projectId);
    },

    createKnot(projectId: string, data: Partial<HyphalKnot>): HyphalKnot {
      requireProject(projectId);
      const members = data.memberNodeIds ?? [];
      if (members.length < 3) throw apiError('ERR_VALIDATION', 'knot requires >=3 member nodes');
      const now = nowIso();
      const id = prefixedId('knot_');
      const knot: HyphalKnot = {
        id, projectId, memberNodeIds: members, internalHyphaeIds: data.internalHyphaeIds ?? [],
        density: data.density ?? 0, status: data.status ?? 'emerging', emergenceConfidence: data.emergenceConfidence ?? 0,
        autoGeneratedSummary: data.autoGeneratedSummary, suggestedName: data.suggestedName ?? [],
        contour: data.contour ?? { center: [0, 0, 0], boundingRadius: 100, controlPoints: [] },
        clusterType: data.clusterType ?? 'normal', paradigmProperties: data.paradigmProperties ?? null,
        topologicalProperties: data.topologicalProperties, createdAt: now, updatedAt: now, deletedAt: null,
      };
      db.prepare(`
        INSERT INTO knots (id, project_id, member_node_ids_json, internal_hyphae_ids_json, density, status,
          emergence_confidence, auto_generated_summary, suggested_name_json, contour_json, cluster_type,
          paradigm_properties_json, topological_properties_json, created_at, updated_at)
        VALUES (@id,@projectId,@members,@internalHyphae,@density,@status,@confidence,@summary,@suggested,@contour,@clusterType,@paradigm,@topological,@now,@now)
      `).run({
        id, projectId, members: toJson(members), internalHyphae: toJson(knot.internalHyphaeIds),
        density: knot.density, status: knot.status, confidence: knot.emergenceConfidence,
        summary: knot.autoGeneratedSummary ?? null, suggested: toJson(knot.suggestedName),
        contour: toJson(knot.contour), clusterType: knot.clusterType,
        paradigm: knot.paradigmProperties ? toJson(knot.paradigmProperties) : null,
        topological: knot.topologicalProperties ? toJson(knot.topologicalProperties) : null, now,
      });
      return this.listKnots(projectId).find((k) => k.id === id)!;
    },

    listKnots(projectId: string, opts: { includeDeleted?: boolean } = {}): HyphalKnot[] {
      const where = opts.includeDeleted ? 'project_id=?' : 'project_id=? AND deleted_at IS NULL';
      const rows = db.prepare(`SELECT * FROM knots WHERE ${where} ORDER BY created_at`).all(projectId);
      return rows.map(rowToKnot);
    },

    confirmKnot(projectId: string, knotId: string, confirmedBy: string): HyphalKnot {
      const t = nowIso();
      const res = db.prepare('UPDATE knots SET status=?, confirmed_at=?, confirmed_by=?, updated_at=? WHERE id=? AND project_id=?')
        .run('confirmed', t, confirmedBy, t, knotId, projectId);
      if (res.changes === 0) throw apiError('ERR_NOT_FOUND', `knot ${knotId} not found`);
      return this.listKnots(projectId).find((k) => k.id === knotId)!;
    },

    dissolveKnot(projectId: string, knotId: string): void {
      const t = nowIso();
      db.prepare('UPDATE knots SET status=?, dismissed_at=?, updated_at=? WHERE id=? AND project_id=?').run('dissolved', t, t, knotId, projectId);
    },
  };
}

Create server/db/repositories/historyRepo.ts

ts 复制代码
import type Database from 'better-sqlite3';
import { prefixedId } from '../../../src/lib/id';
import { toJson, rowToEvent } from './rowMapper';
import type { EventRecord, EventPriority, OperationRecord, OperationStatus, Snapshot, SnapshotType } from '../../../src/types';

export function historyRepo(db: Database.Database) {
  return {
    recordOperation(projectId: string, op: Partial<OperationRecord>): OperationRecord {
      const id = prefixedId('oper_');
      const status: OperationStatus = op.status ?? 'done';
      db.prepare(`
        INSERT INTO operations (id, project_id, operation_type, operator_id, input_ids_json, output_ids_json, params_json, duration_ms, status, error)
        VALUES (@id,@projectId,@type,@operator,@input,@output,@params,@duration,@status,@error)
      `).run({
        id, projectId, type: op.operationType ?? 'unknown', operator: op.operatorId ?? null,
        input: toJson(op.inputIds ?? []), output: toJson(op.outputIds ?? []), params: toJson(op.params ?? {}),
        duration: op.durationMs ?? null, status, error: op.error ?? null,
      });
      const row = db.prepare('SELECT * FROM operations WHERE id=?').get(id) as Record<string, unknown>;
      return {
        id, projectId, operationType: row.operation_type as string, operatorId: (row.operator_id as string | null) ?? undefined,
        inputIds: JSON.parse(row.input_ids_json as string), outputIds: JSON.parse(row.output_ids_json as string),
        params: JSON.parse(row.params_json as string), durationMs: (row.duration_ms as number | null) ?? undefined,
        status, error: (row.error as string | null) ?? undefined, createdAt: row.created_at as string,
      };
    },

    appendEvent(projectId: string, ev: { eventType: string; entityType: string; entityId?: string; payload: unknown; priority: EventPriority }): EventRecord {
      const id = prefixedId('evnt_');
      const seqRow = db.prepare('SELECT COALESCE(MAX(sequence),0) AS s FROM events').get() as { s: number };
      const sequence = seqRow.s + 1;
      db.prepare(`
        INSERT INTO events (id, project_id, sequence, event_type, entity_type, entity_id, payload_json, priority)
        VALUES (@id,@projectId,@sequence,@eventType,@entityType,@entityId,@payload,@priority)
      `).run({
        id, projectId, sequence, eventType: ev.eventType, entityType: ev.entityType,
        entityId: ev.entityId ?? null, payload: toJson(ev.payload), priority: ev.priority,
      });
      const row = db.prepare('SELECT * FROM events WHERE id=?').get(id) as Record<string, unknown>;
      return rowToEvent(row);
    },

    listEvents(projectId: string, opts: { sinceSeq?: number; page: number; pageSize: number; offset: number }): { rows: EventRecord[]; total: number } {
      const base = 'FROM events WHERE project_id=?';
      const params: unknown[] = [projectId];
      if (opts.sinceSeq != null) { base.concat; params.push(opts.sinceSeq); }
      const where = opts.sinceSeq != null ? `${base} AND sequence > ?` : base;
      const total = (db.prepare(`SELECT COUNT(*) AS c ${where}`).all(...params)[0] as { c: number }).c;
      const rows = db.prepare(`SELECT * ${where} ORDER BY sequence LIMIT ? OFFSET ?`).all(...params, opts.pageSize, opts.offset);
      return { rows: rows.map(rowToEvent), total };
    },

    createSnapshot(projectId: string, snap: { snapshotType: SnapshotType; data: unknown; reason?: string }): Snapshot {
      const id = prefixedId('vals_') === 'unused' ? 'snap_' + Math.random().toString(36).slice(2) : 'snap_' + Math.random().toString(36).slice(2);
      db.prepare(`
        INSERT INTO snapshots (id, project_id, snapshot_type, data_json, reason)
        VALUES (@id,@projectId,@type,@data,@reason)
      `).run({ id, projectId, type: snap.snapshotType, data: toJson(snap.data), reason: snap.reason ?? null });
      const row = db.prepare('SELECT * FROM snapshots WHERE id=?').get(id) as Record<string, unknown>;
      return { id, projectId, snapshotType: row.snapshot_type as SnapshotType, data: JSON.parse(row.data_json as string), reason: (row.reason as string | null) ?? undefined, createdAt: row.created_at as string };
    },

    listSnapshots(projectId: string, snapshotType?: SnapshotType): Snapshot[] {
      const rows = snapshotType
        ? db.prepare('SELECT * FROM snapshots WHERE project_id=? AND snapshot_type=? ORDER BY created_at DESC').all(projectId, snapshotType)
        : db.prepare('SELECT * FROM snapshots WHERE project_id=? ORDER BY created_at DESC').all(projectId);
      return rows.map((r) => {
        const row = r as Record<string, unknown>;
        return { id: row.id as string, projectId, snapshotType: row.snapshot_type as SnapshotType, data: JSON.parse(row.data_json as string), reason: (row.reason as string | null) ?? undefined, createdAt: row.created_at as string };
      });
    },
  };
}

注:上面 historyRepo.createSnapshot 的 id 生成行含一个占位表达式,改为规范写法:const id = 'snap_' + Math.random().toString(36).slice(2);(快照不属于数据要素文档的 20 前缀体系,属系统内部记录)。

Create server/db/repositories/metaRepo.ts

ts 复制代码
import type Database from 'better-sqlite3';
import { toJson } from './rowMapper';

export function metaRepo(db: Database.Database) {
  return {
    getMeta(key: string): unknown {
      const row = db.prepare('SELECT value_json FROM meta WHERE key=?').get(key);
      return row ? JSON.parse((row as { value_json: string }).value_json) : undefined;
    },
    setMeta(key: string, value: unknown): void {
      db.prepare(
        'INSERT INTO meta (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=strftime(\'%Y-%m-%dT%H:%M:%fZ\',\'now\')',
      ).run(key, toJson(value));
    },
  };
}

Create server/db/repositories/index.ts

ts 复制代码
import type Database from 'better-sqlite3';
import { projectsRepo } from './projectsRepo';
import { graphRepo } from './graphRepo';
import { historyRepo } from './historyRepo';
import { metaRepo } from './metaRepo';

export interface Repos {
  projects: ReturnType<typeof projectsRepo>;
  graph: ReturnType<typeof graphRepo>;
  history: ReturnType<typeof historyRepo>;
  meta: ReturnType<typeof metaRepo>;
}

export function createRepos(db: Database.Database): Repos {
  return { projects: projectsRepo(db), graph: graphRepo(db), history: historyRepo(db), meta: metaRepo(db) };
}
  • Step 5: 运行测试验证

Run: npx vitest run server/db/__tests__/repos.test.ts

Expected: PASS(6 个用例)。若 createSnapshot 的 id 生成报错,按注修正为 'snap_' + Math.random()... 后重跑。

  • Step 6: Commit
bash 复制代码
git add server/db/repositories/ server/db/__tests__/repos.test.ts
git commit -m "feat(db): repositories for projects/graph/history/meta"

Task 6: REST v1 路由(统一响应 / 分页 / 错误码)

Files:

  • Create: server/routes/v1/projects.ts
  • Create: server/routes/v1/graph.ts
  • Create: server/routes/v1/history.ts
  • Create: server/routes/v1/index.ts
  • Modify: server.ts(挂载 v1 路由 + 启动时 openDb)
  • Test: server/routes/__tests__/v1.test.ts

Interfaces:

  • Consumes: Task 4 getDb()、Task 5 createRepos、Task 3 sendOk/sendErr/parsePagination

  • Produces: 挂载于 /api/v1 的路由:

    • GET/POST /api/v1/projectsGET/PUT/DELETE /api/v1/projects/:id
    • GET/POST /api/v1/projects/:id/nodesPUT/DELETE /api/v1/projects/:id/nodes/:nodeIdPOST .../nodes/:nodeId/restore
    • GET/POST /api/v1/projects/:id/hyphaePUT/DELETE .../hyphae/:hyphaIdPOST .../restore
    • GET/POST /api/v1/projects/:id/knotsPOST .../knots/:knotId/confirmPOST .../knots/:knotId/dissolve
    • GET /api/v1/projects/:id/operationsGET /api/v1/projects/:id/events?sinceSeq=&type=POST /api/v1/projects/:id/snapshots
  • 中间件:路由级错误捕获(async handler 抛错 → sendErr

  • Step 1: 写失败测试

Create server/routes/__tests__/v1.test.ts

ts 复制代码
// @vitest-environment node
import { describe, expect, it, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { openDb, resetDbForTests } from '../../db/db';
import { createRepos } from '../../db/repositories';
import { createV1Router } from '../v1';

let app: express.Express;

beforeEach(() => {
  resetDbForTests();
  const db = openDb(':memory:');
  const repos = createRepos(db);
  app = express();
  app.use(express.json());
  app.use('/api/v1', createV1Router(repos));
});

const seedProject = async () => {
  const res = await request(app).post('/api/v1/projects').send({
    name: '量子认知', rootSeedIdea: '意识是量子现象吗', validationMode: 'hybrid',
    valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 },
    privacyMode: false,
  });
  return res.body.data as { id: string };
};

describe('v1 projects', () => {
  it('create + list + get + soft delete + restore', async () => {
    const p = await seedProject();
    expect(p.id).toMatch(/^proj_/);
    const list = await request(app).get('/api/v1/projects').expect(200);
    expect(list.body.ok).toBe(true);
    expect(list.body.meta.total).toBe(1);
    const del = await request(app).delete(`/api/v1/projects/${p.id}`).expect(200);
    expect(del.body.ok).toBe(true);
    const list2 = await request(app).get('/api/v1/projects').expect(200);
    expect(list2.body.meta.total).toBe(0);
    await request(app).post(`/api/v1/projects/${p.id}/restore`).expect(200);
  });

  it('validates required fields', async () => {
    const res = await request(app).post('/api/v1/projects').send({ name: 'x' }).expect(400);
    expect(res.body.error.code).toBe('ERR_VALIDATION');
  });

  it('returns 404 for missing project', async () => {
    const res = await request(app).get('/api/v1/projects/proj_missing').expect(404);
    expect(res.body.error.code).toBe('ERR_NOT_FOUND');
  });
});

describe('v1 graph', () => {
  it('node CRUD + soft delete + restore', async () => {
    const p = await seedProject();
    const created = await request(app).post(`/api/v1/projects/${p.id}/nodes`).send({ title: '种子', type: 'seed' }).expect(200);
    const nodeId = created.body.data.id;
    expect(nodeId).toMatch(/^node_/);
    const list = await request(app).get(`/api/v1/projects/${p.id}/nodes`).expect(200);
    expect(list.body.data).toHaveLength(1);
    await request(app).put(`/api/v1/projects/${p.id}/nodes/${nodeId}`).send({ title: '改名' }).expect(200);
    const got = await request(app).get(`/api/v1/projects/${p.id}/nodes`).expect(200);
    expect(got.body.data[0].title).toBe('改名');
    await request(app).delete(`/api/v1/projects/${p.id}/nodes/${nodeId}`).expect(200);
    const list2 = await request(app).get(`/api/v1/projects/${p.id}/nodes`).expect(200);
    expect(list2.body.data).toHaveLength(0);
    await request(app).post(`/api/v1/projects/${p.id}/nodes/${nodeId}/restore`).expect(200);
    const list3 = await request(app).get(`/api/v1/projects/${p.id}/nodes`).expect(200);
    expect(list3.body.data).toHaveLength(1);
  });

  it('hypha create requires existing nodes (409/404)', async () => {
    const p = await seedProject();
    const a = (await request(app).post(`/api/v1/projects/${p.id}/nodes`).send({ title: 'A' }).expect(200)).body.data;
    const b = (await request(app).post(`/api/v1/projects/${p.id}/nodes`).send({ title: 'B' }).expect(200)).body.data;
    const h = await request(app).post(`/api/v1/projects/${p.id}/hyphae`).send({ sourceNodeId: a.id, targetNodeId: b.id, type: 'analogical' }).expect(200);
    expect(h.body.data.id).toMatch(/^hyph_/);
    await request(app).post(`/api/v1/projects/${p.id}/hyphae`).send({ sourceNodeId: 'node_missing', targetNodeId: b.id, type: 'causal' }).expect(404);
  });

  it('knot confirm/dissolve lifecycle', async () => {
    const p = await seedProject();
    const ids: string[] = [];
    for (let i = 0; i < 3; i++) ids.push((await request(app).post(`/api/v1/projects/${p.id}/nodes`).send({ title: `N${i}` }).expect(200)).body.data.id);
    const k = await request(app).post(`/api/v1/projects/${p.id}/knots`).send({ memberNodeIds: ids }).expect(200);
    expect(k.body.data.status).toBe('emerging');
    const confirmed = await request(app).post(`/api/v1/projects/${p.id}/knots/${k.body.data.id}/confirm`).send({ confirmedBy: 'test' }).expect(200);
    expect(confirmed.body.data.status).toBe('confirmed');
    await request(app).post(`/api/v1/projects/${p.id}/knots/${k.body.data.id}/dissolve`).expect(200);
    // 少于 3 成员被拒
    await request(app).post(`/api/v1/projects/${p.id}/knots`).send({ memberNodeIds: ids.slice(0, 2) }).expect(400);
  });
});

describe('v1 history', () => {
  it('events with sinceSeq pagination + snapshots', async () => {
    const p = await seedProject();
    await request(app).post(`/api/v1/projects/${p.id}/nodes`).send({ title: 'N' }).expect(200);
    const ev = await request(app).get(`/api/v1/projects/${p.id}/events`).expect(200);
    expect(ev.body.meta.total).toBeGreaterThanOrEqual(1);
    const since = await request(app).get(`/api/v1/projects/${p.id}/events?sinceSeq=${ev.body.data[ev.body.data.length - 1].sequence}`).expect(200);
    expect(since.body.data).toHaveLength(0);
    const snap = await request(app).post(`/api/v1/projects/${p.id}/snapshots`).send({ snapshotType: 'manual', data: { nodes: [] }, reason: 'checkpoint' }).expect(200);
    expect(snap.body.data.snapshotType).toBe('manual');
  });
});
  • Step 2: 运行确认失败

Run: npx vitest run server/routes/__tests__/v1.test.ts

Expected: FAIL(../v1 不存在)。

  • Step 3: 实现路由

Create server/routes/v1/projects.ts

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr, parsePagination } from '../../lib/respond';
import { apiError } from '../../lib/errors';
import type { Repos } from '../../db/repositories';
import type { ValidationMode, ValueScores } from '../../../src/types';

const VALID_MODES: ValidationMode[] = ['deductive', 'simulation', 'hybrid', 'experimental'];

export function projectsRouter(repos: Repos): Router {
  const router = Router();

  router.get('/', (req, res) => {
    try {
      const { page, pageSize, offset } = parsePagination(req.query);
      const result = repos.projects.list({ page, pageSize, offset });
      sendOk(res, result.rows, { page, pageSize, total: result.total });
    } catch (e) { sendErr(res, e); }
  });

  router.post('/', (req, res) => {
    try {
      const body = req.body ?? {};
      if (!body.name || typeof body.name !== 'string' || !body.rootSeedIdea || typeof body.rootSeedIdea !== 'string') {
        throw apiError('ERR_VALIDATION', 'name and rootSeedIdea are required');
      }
      if (!VALID_MODES.includes(body.validationMode)) {
        throw apiError('ERR_VALIDATION', `validationMode must be one of ${VALID_MODES.join(', ')}`);
      }
      const vw = body.valueWeights as ValueScores;
      const keys: (keyof ValueScores)[] = ['survival', 'cognitive', 'aesthetic', 'ethical', 'transcendent'];
      const sum = keys.reduce((acc, k) => acc + (typeof vw?.[k] === 'number' ? vw[k] : 0), 0);
      if (Math.abs(sum - 1) > 0.001) {
        throw apiError('ERR_VALIDATION', 'valueWeights must sum to 1');
      }
      const project = repos.projects.create({
        name: body.name, rootSeedIdea: body.rootSeedIdea, validationMode: body.validationMode,
        valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2, ...body.valueWeights },
        privacyMode: Boolean(body.privacyMode), description: body.description,
      });
      sendOk(res, project);
    } catch (e) { sendErr(res, e); }
  });

  router.get('/:id', (req, res) => {
    try {
      const project = repos.projects.getById(req.params.id);
      if (!project) throw apiError('ERR_NOT_FOUND', 'project not found');
      sendOk(res, project);
    } catch (e) { sendErr(res, e); }
  });

  router.put('/:id', (req, res) => {
    try {
      const project = repos.projects.update(req.params.id, req.body ?? {});
      sendOk(res, project);
    } catch (e) { sendErr(res, e); }
  });

  router.delete('/:id', (req, res) => {
    try {
      repos.projects.softDelete(req.params.id);
      sendOk(res, { id: req.params.id });
    } catch (e) { sendErr(res, e); }
  });

  router.post('/:id/restore', (req, res) => {
    try {
      repos.projects.restore(req.params.id);
      sendOk(res, { id: req.params.id });
    } catch (e) { sendErr(res, e); }
  });

  return router;
}

Create server/routes/v1/graph.ts(nodes/hyphae/knots 三个子路由同文件):

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr } from '../../lib/respond';
import { apiError } from '../../lib/errors';
import type { Repos } from '../../db/repositories';

export function graphRouter(repos: Repos): Router {
  const router = Router();
  const { graph } = repos;

  // ---- nodes ----
  router.get('/nodes', (req, res) => {
    try { sendOk(res, graph.listNodes(req.params.projectId)); }
    catch (e) { sendErr(res, e); }
  });
  router.post('/nodes', (req, res) => {
    try {
      const node = graph.createNode(req.params.projectId, req.body ?? {});
      repos.history.appendEvent(req.params.projectId, { eventType: 'node.created', entityType: 'node', entityId: node.id, payload: { commandType: 'node.create', payload: { id: node.id, title: node.title } }, priority: 0 });
      sendOk(res, node);
    } catch (e) { sendErr(res, e); }
  });
  router.put('/nodes/:nodeId', (req, res) => {
    try {
      const node = graph.updateNode(req.params.projectId, req.params.nodeId, req.body ?? {});
      repos.history.appendEvent(req.params.projectId, { eventType: 'node.updated', entityType: 'node', entityId: node.id, payload: { commandType: 'node.update' }, priority: 0 });
      sendOk(res, node);
    } catch (e) { sendErr(res, e); }
  });
  router.delete('/nodes/:nodeId', (req, res) => {
    try {
      graph.softDeleteNode(req.params.projectId, req.params.nodeId);
      repos.history.appendEvent(req.params.projectId, { eventType: 'node.soft_deleted', entityType: 'node', entityId: req.params.nodeId, payload: { commandType: 'node.soft_delete' }, priority: 0 });
      sendOk(res, { id: req.params.nodeId });
    } catch (e) { sendErr(res, e); }
  });
  router.post('/nodes/:nodeId/restore', (req, res) => {
    try {
      graph.restoreNode(req.params.projectId, req.params.nodeId);
      repos.history.appendEvent(req.params.projectId, { eventType: 'node.restored', entityType: 'node', entityId: req.params.nodeId, payload: { commandType: 'node.restore' }, priority: 0 });
      sendOk(res, { id: req.params.nodeId });
    } catch (e) { sendErr(res, e); }
  });

  // ---- hyphae ----
  router.get('/hyphae', (req, res) => {
    try { sendOk(res, graph.listHyphae(req.params.projectId)); }
    catch (e) { sendErr(res, e); }
  });
  router.post('/hyphae', (req, res) => {
    try {
      const hypha = graph.createHypha(req.params.projectId, req.body ?? {});
      repos.history.appendEvent(req.params.projectId, { eventType: 'hypha.created', entityType: 'hypha', entityId: hypha.id, payload: { commandType: 'hypha.create' }, priority: 0 });
      sendOk(res, hypha);
    } catch (e) { sendErr(res, e); }
  });
  router.delete('/hyphae/:hyphaId', (req, res) => {
    try {
      graph.softDeleteHypha(req.params.projectId, req.params.hyphaId);
      repos.history.appendEvent(req.params.projectId, { eventType: 'hypha.soft_deleted', entityType: 'hypha', entityId: req.params.hyphaId, payload: { commandType: 'hypha.soft_delete' }, priority: 0 });
      sendOk(res, { id: req.params.hyphaId });
    } catch (e) { sendErr(res, e); }
  });
  router.post('/hyphae/:hyphaId/restore', (req, res) => {
    try {
      graph.restoreHypha(req.params.projectId, req.params.hyphaId);
      repos.history.appendEvent(req.params.projectId, { eventType: 'hypha.restored', entityType: 'hypha', entityId: req.params.hyphaId, payload: { commandType: 'hypha.restore' }, priority: 0 });
      sendOk(res, { id: req.params.hyphaId });
    } catch (e) { sendErr(res, e); }
  });

  // ---- knots ----
  router.get('/knots', (req, res) => {
    try { sendOk(res, graph.listKnots(req.params.projectId)); }
    catch (e) { sendErr(res, e); }
  });
  router.post('/knots', (req, res) => {
    try {
      const knot = graph.createKnot(req.params.projectId, req.body ?? {});
      repos.history.appendEvent(req.params.projectId, { eventType: 'knot.created', entityType: 'knot', entityId: knot.id, payload: { commandType: 'knot.confirm' }, priority: 1 });
      sendOk(res, knot);
    } catch (e) { sendErr(res, e); }
  });
  router.post('/knots/:knotId/confirm', (req, res) => {
    try {
      const knot = graph.confirmKnot(req.params.projectId, req.params.knotId, req.body?.confirmedBy ?? 'user');
      repos.history.appendEvent(req.params.projectId, { eventType: 'knot.confirmed', entityType: 'knot', entityId: knot.id, payload: {}, priority: 1 });
      sendOk(res, knot);
    } catch (e) { sendErr(res, e); }
  });
  router.post('/knots/:knotId/dissolve', (req, res) => {
    try {
      graph.dissolveKnot(req.params.projectId, req.params.knotId);
      repos.history.appendEvent(req.params.projectId, { eventType: 'knot.dissolved', entityType: 'knot', entityId: req.params.knotId, payload: {}, priority: 1 });
      sendOk(res, { id: req.params.knotId });
    } catch (e) { sendErr(res, e); }
  });

  return router;
}

Create server/routes/v1/history.ts

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr, parsePagination } from '../../lib/respond';
import type { Repos } from '../../db/repositories';

export function historyRouter(repos: Repos): Router {
  const router = Router();

  router.get('/operations', (req, res) => {
    try {
      const { page, pageSize, offset } = parsePagination(req.query);
      const rows = repos.history.recordOperation && [];
      // 简化 M1:operations 无分页语义,直接返回最近 200 条
      sendOk(res, rows);
    } catch (e) { sendErr(res, e); }
  });

  router.get('/events', (req, res) => {
    try {
      const { page, pageSize, offset } = parsePagination(req.query);
      const sinceSeq = req.query.sinceSeq != null ? Number(req.query.sinceSeq) : undefined;
      const result = repos.history.listEvents(req.params.projectId, { sinceSeq, page, pageSize, offset });
      sendOk(res, result.rows, { page, pageSize, total: result.total });
    } catch (e) { sendErr(res, e); }
  });

  router.post('/snapshots', (req, res) => {
    try {
      const body = req.body ?? {};
      const snap = repos.history.createSnapshot(req.params.projectId, { snapshotType: body.snapshotType ?? 'manual', data: body.data ?? {}, reason: body.reason });
      sendOk(res, snap);
    } catch (e) { sendErr(res, e); }
  });

  return router;
}

注:/operations 端点 M1 简化------补全为直查(下述 index.ts 提供 listOperations 辅助,替代上面临时行)。

Create server/routes/v1/index.ts

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr, parsePagination } from '../../lib/respond';
import type { Repos } from '../../db/repositories';
import { projectsRouter } from './projects';
import { graphRouter } from './graph';
import { historyRouter } from './history';

export function createV1Router(repos: Repos): Router {
  const router = Router();

  router.use('/projects/:projectId', (req, _res, next) => {
    // 提取 projectId 参数供子路由复用
    req.params = { ...req.params, projectId: req.params.projectId };
    next();
  });
  router.use('/projects/:projectId/nodes', graphRouter);
  router.use('/projects/:projectId/hyphae', graphRouter);
  router.use('/projects/:projectId/knots', graphRouter);
  router.use('/projects/:projectId/operations', historyRouter);
  router.use('/projects/:projectId/events', historyRouter);
  router.use('/projects/:projectId/snapshots', historyRouter);
  router.use('/projects', projectsRouter);

  // 补充:operations 直查(historyRouter 内简化行的正式实现)
  router.get('/projects/:projectId/operations', (req, res) => {
    try {
      const db = (repos.history as unknown as { __db: unknown }).__db;
      void db;
      sendErr(res, new Error('unreachable'));
    } catch { /* never */ }
  });

  return router;
}

重要修正:上述 index.ts 的中间件设计有误(graphRouter 内的 req.params.projectId 需要在子路由挂载点提取)。正式实现如下(替代上面全部内容):

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr, parsePagination } from '../../lib/respond';
import type { Repos } from '../../db/repositories';
import { projectsRouter } from './projects';
import { graphRouter } from './graph';
import { historyRouter } from './history';

/** 在 :projectId 上挂载子路由,注入 projectId 参数 */
function mountWithProjectId(parent: Router, repos: Repos, subPath: string, child: Router): void {
  parent.use('/projects/:projectId' + subPath, child);
}

export function createV1Router(repos: Repos): Router {
  const router = Router();

  // 注意:graphRouter/historyRouter 内部通过 req.params.projectId 取项目 ID,
  // Express 路由级联参数在父路径匹配后仍保留在 req.params 中(同层串联挂载 OK)。
  router.use('/projects/:projectId/nodes', graphRouter);
  router.use('/projects/:projectId/hyphae', graphRouter);
  router.use('/projects/:projectId/knots', graphRouter);
  router.use('/projects/:projectId/events', historyRouter);
  router.use('/projects/:projectId/snapshots', historyRouter);
  router.use('/projects', projectsRouter);

  // operations 直查(M1 简化:取最近 200 条)
  const db = (repos as unknown as { __db?: unknown }).__db; // repos 不含 db 引用时改由 createV1Router 第二参注入
  void db;
  router.get('/projects/:projectId/operations', (req, res) => {
    try {
      // 由 createV1Router 传入 db 实现(见下)
      const { page, pageSize, offset } = parsePagination(req.query);
      const total = req.app.locals.db.prepare('SELECT COUNT(*) AS c FROM operations WHERE project_id=?').get(req.params.projectId) as { c: number };
      const rows = req.app.locals.db.prepare('SELECT * FROM operations WHERE project_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?').all(req.params.projectId, pageSize, offset);
      sendOk(res, rows, { page, pageSize, total: total.c });
    } catch (e) { sendErr(res, e); }
  });

  return router;
}

// ---- history.ts 修正:operations 端点删除占位行,直查交给 index.ts 的路由 ----

实现约束createV1Router 需同时接收 db(用于 operations 直查)。签名改为 createV1Router(repos: Repos, db: Database)server.ts 挂载时传入 getDb()history.ts 中删除 /operations 占位路由,只保留 /events/snapshotsindex.tsoperations 路由使用参数 db

  • Step 4: 挂载到 server.ts

Edit server.ts

  1. 文件顶部 import 区加入:
ts 复制代码
import { getDb } from './server/db/db';
import { createRepos } from './server/db/repositories';
import { createV1Router } from './server/routes/v1';
  1. app.use(express.json())(或现有静态服务挂载)之后加入:
ts 复制代码
// ---- M1: v1 REST 层 ----
const db = getDb();
const repos = createRepos(db);
app.locals.db = db;
app.use('/api/v1', createV1Router(repos, db));
  • Step 5: 按修正后的签名更新测试与实现,运行验证

按"重要修正"与"实现约束"调整 index.ts/history.ts 后运行:

Run: npx vitest run server/routes/__tests__/v1.test.ts

Expected: PASS(7 个用例:projects 3 + graph 3 + history 1)。

  • Step 6: 手动冒烟(可选,若 dev server 在跑)
bash 复制代码
curl -s http://localhost:3000/api/v1/projects | head -c 300

Expected: {"ok":true,"data":[],"meta":{...}}

  • Step 7: Commit
bash 复制代码
git add server/routes/ server.ts
git commit -m "feat(api): v1 REST layer (projects/graph/history) with unified responses"

Task 7: SSE 流式任务(TaskManager + /api/v1/llm/stream)

Files:

  • Create: server/lib/taskManager.ts
  • Create: server/routes/v1/llm.ts
  • Modify: server.ts(导出 callUniversalLLM、挂载 llm 路由)
  • Test: server/routes/__tests__/llm.test.ts

Interfaces:

  • Consumes: Task 6 挂载机制、server.ts 现有 callUniversalLLM

  • Produces:

    • createTaskManager(): TaskManagercreateTask(taskId, executor): LlmTasksubscribe(taskId, res)append(taskId, type, data)cancel(taskId)get(taskId)tasks map)
    • 端点:POST /api/v1/llm/tasks {kind, params}{taskId}GET /api/v1/llm/stream?taskId=(SSE);POST /api/v1/llm/tasks/:id/cancel
    • LlmTaskEvent = { type: 'token'|'delta'|'done'|'error'; data: unknown }
  • Step 1: 写失败测试

Create server/routes/__tests__/llm.test.ts

ts 复制代码
import { describe, expect, it, beforeEach, vi } from 'vitest';
import request from 'supertest';
import express from 'express';
import { openDb, resetDbForTests } from '../../db/db';
import { createRepos } from '../../db/repositories';
import { createV1Router } from '../v1';
import { createTaskManager } from '../../lib/taskManager';

let app: express.Express;
let tm: ReturnType<typeof createTaskManager>;

beforeEach(() => {
  resetDbForTests();
  const db = openDb(':memory:');
  const repos = createRepos(db);
  tm = createTaskManager();
  app = express();
  app.use(express.json());
  app.locals.db = db;
  app.use('/api/v1', createV1Router(repos, db, { taskManager: tm }));
});

describe('llm tasks', () => {
  it('creates a task and streams done via SSE', async () => {
    const created = await request(app).post('/api/v1/llm/tasks').send({ kind: 'fission', params: { seed: 'x' } }).expect(200);
    const taskId = created.body.data.taskId;
    expect(taskId).toBeTruthy();

    // 手动推送事件后读流
    const res = await request(app).get(`/api/v1/llm/stream?taskId=${taskId}`).buffer(true).parse((res, cb) => {
      const chunks: Buffer[] = [];
      res.on('data', (c) => { chunks.push(c as Buffer); if (Buffer.concat(chunks).toString().includes('event: done')) { res.destroy(); } });
      res.on('end', () => cb(null, Buffer.concat(chunks)));
      res.on('error', cb);
    });
    expect(res.text).toContain('event: done');
  });

  it('cancel marks task cancelled', async () => {
    const created = await request(app).post('/api/v1/llm/tasks').send({ kind: 'fission', params: {} }).expect(200);
    await request(app).post(`/api/v1/llm/tasks/${created.body.data.taskId}/cancel`).expect(200);
    expect(tm.get(created.body.data.taskId)?.status).toBe('cancelled');
  });

  it('unknown taskId returns 404 on stream', async () => {
    await request(app).get('/api/v1/llm/stream?taskId=nope').expect(404);
  });
});
  • Step 2: 运行确认失败

Run: npx vitest run server/routes/__tests__/llm.test.ts

Expected: FAIL。

  • Step 3: 实现 taskManager

Create server/lib/taskManager.ts

ts 复制代码
import type { Response } from 'express';
import { prefixedId } from '../../src/lib/id';

export type TaskStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled';
export interface LlmTaskEvent { type: 'token' | 'delta' | 'done' | 'error'; data: unknown }
export interface LlmTask {
  id: string;
  status: TaskStatus;
  kind: string;
  events: LlmTaskEvent[];
  subscribers: Set<Response>;
  abortController: AbortController;
  error?: string;
}

export interface TaskManager {
  tasks: Map<string, LlmTask>;
  createTask(kind: string, executor: (task: LlmTask) => Promise<void>): LlmTask;
  append(taskId: string, ev: LlmTaskEvent): void;
  subscribe(taskId: string, res: Response): void;
  cancel(taskId: string): boolean;
  get(taskId: string): LlmTask | undefined;
}

export function createTaskManager(): TaskManager {
  const tasks = new Map<string, LlmTask>();

  const flush = (task: LlmTask) => {
    if (task.subscribers.size === 0) return;
    const last = task.events[task.events.length - 1];
    if (!last) return;
    const payload = `event: ${last.type}\ndata: ${JSON.stringify(last.data)}\n\n`;
    for (const res of task.subscribers) {
      res.write(payload);
      if (last.type === 'done' || last.type === 'error') {
        res.end();
        task.subscribers.delete(res);
      }
    }
  };

  const manager: TaskManager = {
    tasks,
    createTask(kind, executor) {
      const id = prefixedId('oper_'); // SSE 任务复用 oper_ 前缀命名空间
      const task: LlmTask = {
        id, status: 'queued', kind, events: [], subscribers: new Set(),
        abortController: new AbortController(),
      };
      tasks.set(id, task);
      task.status = 'running';
      Promise.resolve()
        .then(() => executor(task))
        .then(() => { task.status = 'done'; manager.append(id, { type: 'done', data: { taskId: id } }); })
        .catch((err) => {
          if (task.status === 'cancelled') return;
          task.status = 'error';
          task.error = err instanceof Error ? err.message : String(err);
          manager.append(id, { type: 'error', data: { message: task.error } });
        })
        .finally(() => {
          // 任务完成后 5 分钟清理
          setTimeout(() => tasks.delete(id), 5 * 60 * 1000).unref?.();
        });
      return task;
    },
    append(taskId, ev) {
      const task = tasks.get(taskId);
      if (!task) return;
      task.events.push(ev);
      flush(task);
    },
    subscribe(taskId, res) {
      const task = tasks.get(taskId);
      if (!task) { res.status(404).end(); return; }
      task.subscribers.add(res);
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');
      res.flushHeaders?.();
      // 重放已完成事件(支持延迟订阅)
      for (const ev of task.events) {
        if (ev.type === 'done' || ev.type === 'error') {
          res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
          res.end();
          task.subscribers.delete(res);
          return;
        }
      }
    },
    cancel(taskId) {
      const task = tasks.get(taskId);
      if (!task) return false;
      if (task.status === 'done' || task.status === 'error') return false;
      task.status = 'cancelled';
      task.abortController.abort();
      manager.append(taskId, { type: 'done', data: { cancelled: true } });
      return true;
    },
    get(taskId) {
      return tasks.get(taskId);
    },
  };
  return manager;
}
  • Step 4: 实现 llm 路由

Create server/routes/v1/llm.ts

ts 复制代码
import { Router } from 'express';
import { sendOk, sendErr } from '../../lib/respond';
import { apiError } from '../../lib/errors';
import type { TaskManager } from '../../lib/taskManager';
import type { Repos } from '../../db/repositories';

export function llmRouter(repos: Repos, taskManager: TaskManager): Router {
  const router = Router();

  router.post('/tasks', (req, res) => {
    try {
      const { kind, params } = req.body ?? {};
      if (!kind) throw apiError('ERR_VALIDATION', 'kind is required');
      // 任务执行器:M1 用「块级转发」模拟打字机(真流式 streamGenerateContent 归 M2)
      const task = taskManager.createTask(kind, async (t) => {
        const text = await runLlmBlock(kind, params, t.abortController.signal);
        const chunkSize = 16;
        for (let i = 0; i < text.length; i += chunkSize) {
          if (t.abortController.signal.aborted) break;
          taskManager.append(t.id, { type: 'token', data: { text: text.slice(i, i + chunkSize) } });
          await new Promise((r) => setTimeout(r, 30));
        }
      });
      sendOk(res, { taskId: task.id });
    } catch (e) { sendErr(res, e); }
  });

  router.get('/stream', (req, res) => {
    const taskId = String(req.query.taskId ?? '');
    if (!taskId) { sendErr(res, apiError('ERR_VALIDATION', 'taskId query param required')); return; }
    taskManager.subscribe(taskId, res);
  });

  router.post('/tasks/:id/cancel', (req, res) => {
    try {
      const ok = taskManager.cancel(req.params.id);
      if (!ok) throw apiError('ERR_NOT_FOUND', `task ${req.params.id} not found or already finished`);
      sendOk(res, { taskId: req.params.id, status: 'cancelled' });
    } catch (e) { sendErr(res, e); }
  });

  return router;
}

/** M1 块级 LLM 调用:包装现有 callUniversalLLM(由 server.ts 注入),返回全文 */
async function runLlmBlock(kind: string, params: unknown, signal: AbortSignal): Promise<string> {
  const { callUniversalLLM } = await import('../../../server');
  const result = await callUniversalLLM({ kind, params, signal });
  const text = typeof result === 'string' ? result : JSON.stringify(result);
  return text;
}

注:runLlmBlock 采用动态 import 避免循环依赖(server.ts 会 import llm 路由)。若 callUniversalLLM 不支持 signal,则忽略该参数继续调用(M1 取消语义由任务标记保证,不强制中断底层请求)。

  • Step 5: 挂载到 server.ts

Edit server.ts

ts 复制代码
import { createTaskManager } from './server/lib/taskManager';
import { llmRouter } from './server/routes/v1/llm';

并在 v1 挂载处:

ts 复制代码
const taskManager = createTaskManager();
app.use('/api/v1/llm', llmRouter(repos, taskManager));
app.locals.taskManager = taskManager;

同时修改 createV1Router 签名以接收可选的 { taskManager }(Task 6 测试已传第三参)。

  • Step 6: 运行测试验证

Run: npx vitest run server/routes/__tests__/llm.test.ts

Expected: PASS(3 个用例)。若 event: done 断言超时,检查 subscribe 重放逻辑(done 已存在时应立即回放)。

  • Step 7: Commit
bash 复制代码
git add server/lib/taskManager.ts server/routes/v1/llm.ts server.ts
git commit -m "feat(api): SSE task manager with /api/v1/llm tasks and stream"

Task 8: 遗留 API 埋点适配层(legacyBridge)

Files:

  • Create: server/lib/legacyBridge.ts
  • Modify: server.ts(5 处核心遗留 API 插入埋点:fission、fusion execute-advanced、metabolism、expansion、pruning)
  • Test: server/lib/__tests__/legacyBridge.test.ts

Interfaces:

  • Consumes: Task 4/5 的 getDb/createReposserver.ts 现有 handler 响应

  • Produces: legacyBridge.record(repos, {projectId, operationType, inputIds, outputIds, params, status, error?, durationMs?}) ------ 写 operations 行 + 对应 events 行(P1)

  • Step 1: 写失败测试

Create server/lib/__tests__/legacyBridge.test.ts

ts 复制代码
import { describe, expect, it, beforeEach } from 'vitest';
import { openDb, resetDbForTests } from '../../db/db';
import { createRepos } from '../../db/repositories';
import { recordLegacyOperation } from '../legacyBridge';

let repos: ReturnType<typeof createRepos>;

beforeEach(() => {
  resetDbForTests();
  repos = createRepos(openDb(':memory:'));
});

describe('legacyBridge', () => {
  it('records operation + events for legacy API calls', () => {
    const p = repos.projects.create({ name: 'p', rootSeedIdea: 'i', validationMode: 'deductive', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
    recordLegacyOperation(repos, { projectId: p.id, operationType: 'fission', inputIds: ['node_a'], outputIds: ['node_b', 'node_c'], params: { operator: 'ontology_split', temperature: 'balanced' }, status: 'done', durationMs: 1234 });
    const ops = (repos.history as unknown as { __ops: unknown }).__ops;
    void ops;
    // 通过直查验证(historyRepo 未暴露 listOperations,用 db 快照断言)
    const evts = repos.history.listEvents(p.id, { page: 1, pageSize: 50, offset: 0 });
    expect(evts.total).toBeGreaterThanOrEqual(1);
    expect(evts.rows.some((e) => e.eventType === 'operation.recorded')).toBe(true);
  });
});
  • Step 2: 运行确认失败

Run: npx vitest run server/lib/__tests__/legacyBridge.test.ts

Expected: FAIL(../legacyBridge 不存在)。

  • Step 3: 实现 legacyBridge

Create server/lib/legacyBridge.ts

ts 复制代码
import type { Repos } from '../db/repositories';
import type { OperationStatus } from '../../src/types';

export interface LegacyOpInput {
  projectId: string;
  operationType: string;
  inputIds: string[];
  outputIds: string[];
  params: Record<string, unknown>;
  status?: OperationStatus;
  error?: string;
  durationMs?: number;
}

export function recordLegacyOperation(repos: Repos, input: LegacyOpInput): void {
  const op = repos.history.recordOperation(input.projectId, {
    operationType: input.operationType,
    inputIds: input.inputIds,
    outputIds: input.outputIds,
    params: input.params,
    status: input.status ?? 'done',
    error: input.error,
    durationMs: input.durationMs,
  });
  repos.history.appendEvent(input.projectId, {
    eventType: 'operation.recorded',
    entityType: 'operation',
    entityId: op.id,
    payload: { operationType: input.operationType, status: input.status ?? 'done' },
    priority: 1,
  });
}
  • Step 4: 在 server.ts 插入 5 处埋点

server.ts 中以下 5 个路由的 handler,在成功响应前插入埋点(模式一致,以 fission 为例;其余 4 处同型替换路由名与参数提取):

ts 复制代码
// /api/fission handler 内、res.json 之前:
try {
  recordLegacyOperation(repos, {
    projectId: req.body?.projectId ?? req.body?.id ?? '',
    operationType: 'fission',
    inputIds: [req.body?.seedId ?? req.body?.nodeId ?? ''],
    outputIds: result?.nodes?.map((n: { id: string }) => n.id) ?? [],
    params: { operator: req.body?.operatorId, temperature: req.body?.temperature },
  });
} catch { /* 埋点失败不影响主流程 */ }

/api/fuse(含 execute-advanced)、/api/metabolism/*(取各 route 的 projectId 来源)、/api/expansion/*/api/pruning/evaluate 重复上述模式,operationType 分别为 'fusion'/'metabolism.tick'/'expansion'/'pruning'

server.ts 顶部 import:

ts 复制代码
import { recordLegacyOperation } from './server/lib/legacyBridge';

repos 已在 Task 6 Step 4 挂载时定义为模块级变量,埋点处直接引用。)

  • Step 5: 运行测试验证

Run: npx vitest run server/lib/__tests__/legacyBridge.test.ts

Expected: PASS。再启动 dev server 验证既有 API 不回归:

bash 复制代码
npm run dev &
curl -s http://localhost:3000/api/llm/test

Expected: 原有 JSON 响应(此前一致),server 日志无异常。

  • Step 6: Commit
bash 复制代码
git add server/lib/legacyBridge.ts server.ts
git commit -m "feat(api): legacy bridge records operations+events on core LLM APIs"
相关推荐
长三角活动观察1 小时前
苏州独石传媒项目SOP拆解:从苏州智博会到出海大会,千人级活动的流程管控方法论
大数据·人工智能·传媒
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章103-空车位识别
人工智能·opencv·计算机视觉
特立独行的猫a1 小时前
Tauri v2的Rust应用 → HarmonyOS(鸿蒙 PC)移植30分钟速成指南
开发语言·rust·harmonyos·tauri·移植·鸿蒙pc
迷迭香yy1 小时前
大宗交易折溢价因子怎么挖掘本地化Python全流程实战
开发语言·人工智能·python
也非非也1 小时前
DeepSeek 又开源了一个新东西——DeepSeek Harness
人工智能·开源·agi·deepseek·harness·dsh
xieliyu.1 小时前
UPD协议结构以及开发中注意事项
java·开发语言·笔记·java-ee
AINative软件工程1 小时前
Agent 上下文账本工程:别让工具结果把 128K 窗口塞成垃圾场
后端·架构·ai编程
海兰1 小时前
【AI工具】腾讯云开源自研 AI 助手 Octop介绍及安装使用指南
人工智能·云计算·腾讯云
Yweir1 小时前
AI大模型开发-Python介绍、版本说明
开发语言·人工智能·python