Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ npm run fmt:check # oxfmt --check —— 必须通过(与 fmt 重
## 设计底线

- **Facade 完整性**:命令永远不越过 `DatabaseWorkspaceService`。所有委托(`manager`、`history`、`favorites`、`relationGraph`)都是 facade 的私有字段。命令需要新行为时,在 facade 上暴露专用方法——不要穿透到内部。
- **每种操作单一执行路径**:所有读查询流经 `DatabaseConnectionManager.executeQuery`(只读守卫 + LIMIT)。所有写变更流经 `DatabaseConnectionManager.executeMutation`(`prepareMutationQuery` 拒绝 DDL,人工确认门)。绝不创建新的查询路径或绕过 `sql-policy.ts`。
- **每种操作单一执行路径**:所有读查询流经 `DatabaseConnectionManager.executeQuery`(只读守卫 + LIMIT)。所有写变更流经 `DatabaseWorkspaceService.executeMutationWithApproval`(facade 内 `prepareMutationQuery` 拒绝 DDL + 注入的人工确认回调)。绝不创建新的查询路径或绕过 `sql-policy.ts`。
- **纯工具函数**:`sql-policy.ts`(只读守卫 + LIMIT 注入 + DML 校验)和 `formatting/result-table.ts` 是纯函数、无副作用。保持这一点——不导入 pi、不做 I/O。当逻辑不需要 pi API 时,抽成纯函数以便用普通值测试。
- **测试接缝**:模块通过构造函数参数接受依赖(`StateStore`、`Database` 句柄、`QueryFn`)。不要引入硬编码路径(如 `homedir()`)或单例——注入接缝。
- **实时 schema,无缓存**:`getTables()` 和 `getTableSchema()` 总是查询实时 DB(`information_schema`)。没有 schema 缓存需要刷新或保持一致——实时查询足够廉价且永不过期。没有实测需求就不要重新引入缓存层。
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ formatting/ ← formatTableResult —— 自动布局:横向 / 转置 /
### 关键设计模式

- **深度工作空间模块**:`DatabaseWorkspaceService` 将 WorkspaceContext + QueryRunner 吸收进一个类。所有委托(`manager`、`history`、`favorites`、`relationGraph`)都是私有字段——命令通过约 23 个专用方法穿越外部接缝。任何命令都不能越过 facade。
- **单一执行点**:所有读查询经过 `DatabaseConnectionManager.executeQuery`,它应用只读守卫和 LIMIT 策略(`connection/sql-policy.ts`——纯函数,`READONLY_SQL_RE` 的唯一归属),然后在检出的专用连接上执行(`getConnection → USE → query → release`),这样 USE 与查询不会散落在连接池的不同连接上。无界 SELECT 自动追加 `LIMIT n`(默认 100,connections.yaml 中可配 per-connection `queryLimit`);最终 SQL 通过 `result.sql` 返回,用户可以看到自动追加的 LIMIT。写操作经过 `DatabaseConnectionManager.executeMutation`——没有只读守卫,但由 `prepareMutationQuery`(拒绝 DDL)和强制人工确认对话框把关。命令处理器只在分发时(表名 vs SQL)导入 `READONLY_SQL_RE`,绝不用于执行期校验。
- **单一执行点**:所有读查询经过 `DatabaseConnectionManager.executeQuery`,它应用只读守卫和 LIMIT 策略(`connection/sql-policy.ts`——纯函数,`READONLY_SQL_RE` 的唯一归属),然后在检出的专用连接上执行(`getConnection → USE → query → release`),这样 USE 与查询不会散落在连接池的不同连接上。无界 SELECT 自动追加 `LIMIT n`(默认 100,connections.yaml 中可配 per-connection `queryLimit`);最终 SQL 通过 `result.sql` 返回,用户可以看到自动追加的 LIMIT。写操作经过 `DatabaseWorkspaceService.executeMutationWithApproval`——唯一写入口,在 facade 内完成 `prepareMutationQuery`(DDL 拒绝,抛 `MutationValidationError`)→ 人工确认(注入的确认回调,生产为 `showMutationConfirm`)→ 执行。命令处理器只在分发时(表名 vs SQL)导入 `READONLY_SQL_RE`,绝不用于执行期校验。
- **实时 schema**:`getTables()` 和 `getTableSchema()` 总是查询 `information_schema`——无缓存、无刷新。实践中足够廉价且永不过期。
- **BFS 自动 JOIN**:`RelationGraph.bfsQuery()` 遍历内存前向图,每跳发出参数化(`IN (?)`)、schema 限定的查询。深度受限(默认 2,最大 5)。它从调用方接收 `QueryFn` 而非 mysql2 连接池——图保持数据库无关,用 stub 测试。
- **懒加载工作空间初始化**:`DatabaseWorkspaceService` 不在扩展工厂中构造(工厂可能运行在从不启动会话的调用中,如 `--list-models` 或 print 模式)。懒 getter 将打开 SQLite / 读取配置推迟到 `session_start`、第一次 `/db` 命令或第一次工具调用。
Expand All @@ -76,7 +76,7 @@ formatting/ ← formatTableResult —— 自动布局:横向 / 转置 /

`db_query` 和 `db_tables` 默认使用工作空间选择,但接受可选的 `connection` / `database` 覆盖,由 `DatabaseWorkspaceService.resolveTarget` 解析(显式 connection 不带 database 时回退到其 `defaultDatabase`)。`db_list_relations` / `db_relation` 接受可选的 `database` 覆盖。同一 MySQL 实例上的数据库可以用 `db.table` 限定名直接 JOIN——连接池不带默认数据库连接,所以 `USE` 从来不是沙箱。

只读工具 `db_query` 经过 `DatabaseWorkspaceService.executeQuery`,因此只读守卫和 LIMIT 策略生效。`db_tables` 使用与 `/db` 命令相同的实时 `information_schema` 查询(`getTables` / `getTableSchema`)。`db_discover` 读取本地连接配置并通过 manager 上的 `SHOW DATABASES` 列出数据库。`db_list_relations` / `db_relation` 通过 `RelationGraph` / `RelationStore` 操作本地 SQLite——它们不触碰 MySQL,不需要只读守卫或确认门(register 幂等,delete 按精确列对匹配)。`db_mutate` 使用 `executeMutation`——没有只读守卫,但 `prepareMutationQuery` 拒绝 DDL,每次执行都有确认对话框把关。`db_tools`(loader)只操作激活工具集。
只读工具 `db_query` 经过 `DatabaseWorkspaceService.executeQuery`,因此只读守卫和 LIMIT 策略生效。`db_tables` 使用与 `/db` 命令相同的实时 `information_schema` 查询(`getTables` / `getTableSchema`)。`db_discover` 读取本地连接配置并通过 manager 上的 `SHOW DATABASES` 列出数据库。`db_list_relations` / `db_relation` 通过 `RelationGraph` / `RelationStore` 操作本地 SQLite——它们不触碰 MySQL,不需要只读守卫或确认门(register 幂等,delete 按精确列对匹配)。`db_mutate` 调用 `executeMutationWithApproval`——校验与确认都发生在 facade 内,工具只注入确认对话框回调并整形结果。`db_tools`(loader)只操作激活工具集。

### 消息渲染

Expand Down
97 changes: 95 additions & 2 deletions __tests__/workspace-target.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { StateStore } from "../state/state-store";
import { DatabaseWorkspaceService } from "../state/workspace";
import { DatabaseWorkspaceService, type MutationApprovalRequest } from "../state/workspace";
import { MutationValidationError } from "../connection/sql-policy";

const CONNECTIONS_YAML = `connections:
main:
Expand Down Expand Up @@ -256,3 +257,95 @@ describe("DatabaseWorkspaceService target resolution", () => {
expect(deletedAgain).toBe(false);
});
});

describe("DatabaseWorkspaceService.executeMutationWithApproval", () => {
const dirs: string[] = [];
const services: DatabaseWorkspaceService[] = [];

function makeWorkspace(): DatabaseWorkspaceService {
const dir = mkdtempSync(join(tmpdir(), "ws-mut-test-"));
dirs.push(dir);
writeFileSync(join(dir, "connections.yaml"), CONNECTIONS_YAML);
const ws = new DatabaseWorkspaceService(new StateStore(dir));
services.push(ws);
return ws;
}

afterEach(() => {
for (const ws of services.splice(0)) ws.destroy();
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

it("rejects DDL before invoking the confirm callback", async () => {
const ws = makeWorkspace();
ws.switchTo("prod", "main", "appdb");
const confirm = vi.fn<() => Promise<boolean>>(async () => true);

await expect(ws.executeMutationWithApproval("DROP TABLE users", {}, confirm)).rejects.toThrow(
MutationValidationError,
);
expect(confirm).not.toHaveBeenCalled();
});

it("throws before confirm when no target can be resolved", async () => {
const ws = makeWorkspace();
const confirm = vi.fn<() => Promise<boolean>>(async () => true);

await expect(
ws.executeMutationWithApproval("UPDATE t SET a=1 WHERE id=1", {}, confirm),
).rejects.toThrow(/No database selected/);
expect(confirm).not.toHaveBeenCalled();
});

it("returns rejected when the user declines, without touching the manager", async () => {
const ws = makeWorkspace();
ws.switchTo("prod", "main", "appdb");
const confirm = vi.fn<() => Promise<boolean>>(async () => false);

const outcome = await ws.executeMutationWithApproval(
"UPDATE t SET a=1 WHERE id=1",
{},
confirm,
);
expect(outcome.status).toBe("rejected");
// 若误触达 manager.executeMutation,测试会因连不上 MySQL 抛错,
// 而不是返回 rejected——因此这行足以证明执行路径未被走。
});

it("passes the merged validation + target request to confirm", async () => {
const ws = makeWorkspace();
ws.switchTo("prod", "main", "appdb");
let received: MutationApprovalRequest | undefined;
const confirm = async (req: MutationApprovalRequest) => {
received = req;
return false; // 只验证 request 形状,不真正执行
};

await ws.executeMutationWithApproval(
"UPDATE t SET a=1 WHERE id=1",
{ connectionId: "other", database: "stagingdb" },
confirm,
);
expect(received).toEqual({
sql: "UPDATE t SET a=1 WHERE id=1",
operation: "UPDATE",
warning: undefined,
connectionId: "other",
database: "stagingdb",
});
});

it("carries the no-WHERE warning into the request", async () => {
const ws = makeWorkspace();
ws.switchTo("prod", "main", "appdb");
let received: MutationApprovalRequest | undefined;
const confirm = async (req: MutationApprovalRequest) => {
received = req;
return false;
};

await ws.executeMutationWithApproval("DELETE FROM logs", {}, confirm);
expect(received?.operation).toBe("DELETE");
expect(received?.warning).toMatch(/WHERE/);
});
});
11 changes: 2 additions & 9 deletions commands/mutate-confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,7 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import { Container, Text, Spacer, matchesKey, Key } from "@earendil-works/pi-tui";

export interface MutationConfirmParams {
sql: string;
operation: "INSERT" | "UPDATE" | "DELETE" | "REPLACE";
warning?: string;
connectionId: string;
database: string;
}
import type { MutationApprovalRequest } from "../state/workspace";

type StyleColor = "success" | "warning" | "error";

Expand All @@ -38,7 +31,7 @@ const DEFAULT_WARNING = "该操作将永久修改数据,无法撤销";
*/
export async function showMutationConfirm(
ctx: ExtensionContext,
params: MutationConfirmParams,
params: MutationApprovalRequest,
): Promise<boolean> {
if (ctx.mode !== "tui") {
const label = `${OP_STYLE[params.operation]?.icon ?? "⚠️"} ${params.operation}`;
Expand Down
13 changes: 12 additions & 1 deletion connection/sql-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export interface MutationValidation {
warning?: string;
}

/**
* 变更校验失败(非 DML / DDL / 未识别语句)时抛出的错误类型。
* 让调用方能区分「SQL 本身不合法」与「执行期失败」。
*/
export class MutationValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "MutationValidationError";
}
}

/** 应用于无尾部 LIMIT 的 SELECT 语句的默认行数上限。 */
export const DEFAULT_QUERY_LIMIT = 100;

Expand Down Expand Up @@ -56,7 +67,7 @@ export function prepareReadOnlyQuery(sql: string, limit: number = DEFAULT_QUERY_
export function prepareMutationQuery(sql: string): MutationValidation {
const trimmed = sql.trim();
if (!MUTATION_SQL_RE.test(trimmed)) {
throw new Error(
throw new MutationValidationError(
"仅允许 DML 写操作(INSERT、UPDATE、DELETE、REPLACE)。" +
"DDL(CREATE、DROP、ALTER、TRUNCATE)被禁止。",
);
Expand Down
26 changes: 14 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,19 +202,23 @@ bfsQuery("orders", rows, maxDepth=2, limit=10)

### 3.7 数据修改工具 — db_mutate

`db_mutate` 是唯一的写路径,设计原则是「AI 提议,人类批准」:
`db_mutate` 是唯一的写路径,设计原则是「AI 提议,人类批准」。仪式(校验 + 人工确认)由 facade 持有

```
LLM 调用 db_mutate({ sql, connection?, database? })
├─ 1. prepareMutationQuery(sql) ← DML 校验(DDL 直接拒绝)
ws.executeMutationWithApproval(sql, opts, confirm) ← facade 唯一写入口
├─ 1. prepareMutationQuery(sql) ← DML 校验(DDL 抛 MutationValidationError)
├─ 2. resolveTarget(opts) ← 目标解析
├─ 3. showMutationConfirm() ← TUI overlay 弹窗
├─ 3. confirm({ 校验结果 + 目标 }) ← showMutationConfirm TUI overlay
│ ├─ Enter → 确认
│ └─ Esc → 取消(返回给 LLM)
│ └─ Esc → 返回 { status: "rejected" } 给 LLM(非错误
└─ 4. manager.executeMutation() ← 执行,返回 affectedRows
```

确认回调由调用方注入(生产 = `showMutationConfirm`,测试 = stub),facade 保持 pi-free。

**安全边界**:

- DDL(CREATE/DROP/ALTER/TRUNCATE)硬性拒绝,不弹窗
Expand Down Expand Up @@ -319,15 +323,13 @@ MySQL 的 `information_schema.KEY_COLUMN_USAGE` 只能发现已定义的外键

```
1. tools/db-tools.ts: db_mutate.execute()
├─ prepareMutationQuery(sql) ← DML 校验
├─ ws.resolveTarget(opts) ← 目标解析
└─ showMutationConfirm(ctx, ...) ← TUI overlay 确认
├─ 用户按 Esc → 返回 rejected 给 LLM
└─ ws.executeMutationWithApproval(sql, opts,
(req) => showMutationConfirm(ctx, req)) ← 注入确认回调
└─ 用户按 Enter ↓
2. workspace.ts: executeMutation()
2. workspace.ts: executeMutationWithApproval() ← facade 持有仪式
├─ prepareMutationQuery(sql) ← DML 校验(DDL 拒绝)
├─ resolveTarget(opts) ← 目标解析
├─ confirm({ 校验结果 + 目标 }) ← 用户 Esc → rejected / Enter → 继续
└─ manager.executeMutation(connId, db, sql)
3. db-manager.ts: executeMutation()
Expand Down
91 changes: 59 additions & 32 deletions docs/mutation-tool-design.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# AI 数据修改工具 — 设计方案

> 允许 AI 通过 `db_mutate` 工具发起 INSERT/UPDATE/DELETE/REPLACE,但 **必须经过人工在 TUI 中确认** 后才能执行。
>
> 状态:已实施(v0.8.x)。后续演进:校验 + 人工确认已收回 facade ——
> 现为 `DatabaseWorkspaceService.executeMutationWithApproval(sql, opts, confirm)`
> 单一写入口(见 §2/§5 更新),工具层只做参数装配与结果整形。

## 目录

Expand Down Expand Up @@ -49,30 +53,22 @@
LLM 调用 db_mutate(sql)
tools/db-tools.ts ──► prepareMutationQuery(sql) ← connection/sql-policy.ts
│ 校验通过?
│ ├─ 否 → 返回错误给 LLM
│ └─ 是 ↓
tools/db-tools.ts ──► ws.executeMutationWithApproval(sql, opts, confirm)
│ └─ confirm = (req) => showMutationConfirm(ctx, req)
ctx.ui.custom({ overlay: true })
state/workspace.ts(facade —— 仪式唯一归属)
MutationConfirmDialog ← 新组件 (commands/mutate-confirm.ts)
显示 SQL + 操作类型 + 目标数据库
用户:Enter 确认 / Esc 取消
├─ 用户取消 → 返回 { confirmed: false } 给 LLM
├─ 1. prepareMutationQuery(sql) ← connection/sql-policy.ts
│ 校验通过? 否(DDL 等)→ 抛 MutationValidationError → isError 给 LLM
│ 是 ↓
├─ 2. resolveTarget(opts)
├─ 3. confirm({ 校验结果 + 目标 }) —— commands/mutate-confirm.ts
│ ├─ 用户取消 → 返回 { status: "rejected" } → 非 isError 回显给 LLM
│ └─ 用户确认 ↓
├─ 4. manager.executeMutation() ← connection/db-manager.ts
└─ 用户确认 ↓
ws.executeMutation(sql) ← state/workspace.ts (新方法)
manager.executeMutation() ← connection/db-manager.ts (新方法)
返回 affectedRows + elapsed 给 LLM
返回 { status: "executed", affectedRows, elapsed, sql, connectionId, database }
```

---
Expand Down Expand Up @@ -183,25 +179,56 @@ async executeMutation(
### 5.1 新增 `state/workspace.ts`

```typescript
/** Execute a data mutation (INSERT/UPDATE/DELETE/REPLACE). */
async executeMutation(
sql: string,
opts?: { connectionId?: string; database?: string },
): Promise<{
affectedRows: number;
elapsed: string;
/** 写操作确认请求:校验结果 + 解析后的目标。 */
export interface MutationApprovalRequest {
sql: string;
operation: "INSERT" | "UPDATE" | "DELETE" | "REPLACE";
warning?: string;
connectionId: string;
database: string;
}> {
}

/** 写操作结果:用户拒绝是正常结果(rejected),非异常。 */
export type MutationOutcome =
| { status: "rejected"; sql: string }
| {
status: "executed";
affectedRows: number;
elapsed: string;
sql: string;
connectionId: string;
database: string;
};

/** 唯一写入口——持有完整仪式:校验 → 人工确认 → 执行。 */
async executeMutationWithApproval(
sql: string,
opts: { connectionId?: string; database?: string },
confirm: (req: MutationApprovalRequest) => Promise<boolean>,
): Promise<MutationOutcome> {
const validation = prepareMutationQuery(sql); // DDL → 抛 MutationValidationError,不进入确认
const target = this.resolveTarget(opts);

const approved = await confirm({
sql: validation.sql,
operation: validation.operation,
warning: validation.warning,
connectionId: target.connectionId,
database: target.database,
});
if (!approved) return { status: "rejected", sql: validation.sql };

const result = await this.manager.executeMutation(
target.connectionId,
target.database,
sql,
validation.sql,
);
this._lastSql = sql;
return { ...result, connectionId: target.connectionId, database: target.database };
return {
status: "executed",
...result,
connectionId: target.connectionId,
database: target.database,
};
}
```

Expand Down
Loading
Loading