diff --git a/ACCEPTANCE_CHECKLIST.md b/ACCEPTANCE_CHECKLIST.md index 3423741..04daeec 100644 --- a/ACCEPTANCE_CHECKLIST.md +++ b/ACCEPTANCE_CHECKLIST.md @@ -5,8 +5,12 @@ - 当前环境中,项目配置声明的主工作区路径为 `/workspace/repo/dbtool-cli-v1`。 - QA 首次盘点时,该路径原本不存在;当前已经补齐 Rust workspace、CLI、demo seed 资产、smoke runbook 和 release-smoke workflow 基线。 - 2026-03-26 本地 Linux 已执行:CLI `--help`、`--version`、SQLite `connect` / `inspect` / `query` / `export`、SQLite seed 重放、Linux artifact checksum 与 artifact smoke。 -- 2026-03-26 当前 QA runner 再次确认:`node v24.14.0` 可用,`docker` 与 `cargo` 均不存在;因此本机只能继续复核预编译二进制与 SQLite 路径,不能在此处完成 PostgreSQL / MySQL 的 Docker-backed smoke。 -- 当前检查表仍不能直接宣布通过,因为 PostgreSQL / MySQL 的 Docker-backed smoke 与 GitHub 的 macOS / Windows release runner 还未在本 agent 环境实测。 +- 2026-03-26 `CMP-17` 评论已回填宿主机 smoke 结果:PostgreSQL / MySQL happy path 已在具备 Docker 的宿主机完成,说明“QA 在宿主机执行、数据库由仓库内 compose 临时拉起”的推荐路径成立。 +- 2026-03-26 当前 QA runner 仍确认:`node v24.14.0` 可用,`docker` 不在 PATH;Rust toolchain 可通过显式 PATH 与 `zig` 包装器调用,但本 agent 仍无法在本地拉起 Docker 数据库。 +- 2026-03-26 已补做 MySQL 修复的离线回归:`cargo test -p db-drivers mysql_` 通过,`cargo test -p dbtool-cli` 通过,`cargo build -p dbtool-cli --release` 通过,且 `target/release/dbtool` 已包含 `SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci`。 +- 2026-03-27 新的宿主机评论与 [CMP-22](/CMP/issues/CMP-22#comment-9b4ec6e1-408d-483d-a319-818be09d6d79) 指出:在干净重建并显式 UTF-8 导入后,MySQL 官方 CLI、`dbtool query` 与 `dbtool export` 均正常,说明问题边界进一步收敛到 bootstrap/import 字符集路径。 +- 2026-03-27 [CMP-23](/CMP/issues/CMP-23#comment-ab7a7c16-aaa3-4e62-9543-5055925201b8) 已补齐宿主机 patched-run 证据:`character_set_%` / `collation_%` 变量、MySQL 官方 CLI 查询结果、`dbtool query` 结果、`dbtool export` CSV/JSON 摘要均已附上,且 `张敏`、`权限验证`、emoji 显示正常。 +- 当前检查表仍不能直接宣布整体通过,因为 GitHub 的 macOS / Windows release runner 仍缺实测证据;MySQL Unicode 缺陷已被收敛并具备关闭 `CMP-23` 的证据,不再是当前 backend blocker。 ## 与 PM / CTO 的当前对齐依据 @@ -18,7 +22,7 @@ - [x] 项目仓库已落地到可访问工作区,路径可重复获取 - [x] 安装、启动、demo、seed、smoke、Docker 路径存在且写入文档 -- [ ] PostgreSQL、MySQL、SQLite 三类数据库均有可执行验证步骤 +- [x] PostgreSQL、MySQL、SQLite 三类数据库均有可执行验证步骤 - [x] macOS、Linux、Windows 的期望行为已记录 - [x] 失败路径可复现、可记录、可区分严重级别 - [ ] 验收证据可由非开发者重复执行 @@ -37,21 +41,21 @@ - [x] demo 数据准备步骤存在 - [x] seed 步骤存在且幂等 - [x] smoke 脚本存在且可一键执行 -- [ ] Dockerfile 或 compose 路径存在并可运行 +- [x] Dockerfile 或 compose 路径存在并可运行 - [x] 上述路径都有预期输出与失败输出说明 ## 连接验证 ### PostgreSQL -- [ ] 有效连接参数可成功连接 +- [x] 有效连接参数可成功连接 - [ ] 无效 host / port / database / user / password 会失败 - [ ] 网络不可达时返回清晰错误 - [ ] 权限不足时返回清晰错误 ### MySQL -- [ ] 有效连接参数可成功连接 +- [x] 有效连接参数可成功连接 - [ ] 无效 host / port / database / user / password 会失败 - [ ] 网络不可达时返回清晰错误 - [ ] 权限不足时返回清晰错误 @@ -106,7 +110,15 @@ - SQLite `connect` failure path:non-db file 现在返回非零退出码,并输出 `connection failed: file is not a database` - SQLite seed:`examples/scripts/bootstrap-sqlite.sh` 使用 Node fallback 成功重放 - Linux artifact:`dist/dbtool-0.1.0-x86_64-unknown-linux-gnu.tar.gz` checksum 通过,解包后二进制 `--help` / `--version` 通过 -- 当前 runner 能力盘点:`node v24.14.0` 存在;`sqlite3`、`python3`、`docker`、`cargo` 不存在 +- 宿主机 Docker smoke:根据 [CMP-17](/CMP/issues/CMP-17#comment-0cd53e93-25a5-456b-8dba-31ea098c2aa8) 的 2026-03-26 评论,PostgreSQL happy path 与 MySQL happy path 已在具备 Docker 的宿主机完成,推荐执行路径已被验证成立 +- 宿主机 Unicode 复核:后续 patched-run 已确认 `dbtool query` / `dbtool export` 在干净重建 + 显式 UTF-8 导入后可正确显示中文与 emoji,因此此前宿主机乱码结论已被后续证据覆盖 +- MySQL 修复离线回归:`cargo test -p db-drivers mysql_` 通过,覆盖 `mysql_connection_enforces_utf8mb4_session` 与 `mysql_string_values_preserve_multilingual_utf8` +- CLI 回归:`cargo test -p dbtool-cli` 13 个测试全部通过;`cargo build -p dbtool-cli --release` 成功,`target/release/dbtool --help` / `--version` 通过 +- bootstrap 脚本修复:`examples/scripts/bootstrap-mysql.sh` 已显式加入 `--default-character-set=utf8mb4`,且 `bash -n examples/scripts/bootstrap-mysql.sh` 通过 +- 新宿主机线索:根据 [CMP-22](/CMP/issues/CMP-22#comment-9b4ec6e1-408d-483d-a319-818be09d6d79) 与 [CMP-23](/CMP/issues/CMP-23#comment-37f6a300-ff0f-4e62-a18c-5f4fdc6ee0fa),干净重建后 `dbtool` 命令在宿主机已报告通过 +- 宿主机 patched-run 证据:根据 [CMP-23](/CMP/issues/CMP-23#comment-ab7a7c16-aaa3-4e62-9543-5055925201b8),`SHOW VARIABLES LIKE 'character_set_%'` / `collation_%` 已留档;MySQL 官方 CLI、`dbtool query`、`dbtool export` 都正确显示 `张敏`、`权限验证`、`emoji smoke 😀` +- 当前 runner 能力盘点:`node v24.14.0` 存在;`docker` 不在 PATH;Rust toolchain 可经显式 PATH 使用;本地端口 `127.0.0.1:53306` 与 `127.0.0.1:55432` 当前均关闭 +- MySQL live retest blocker:对 `127.0.0.1:53306` 执行 `connect`、happy-path `query`、`SHOW VARIABLES LIKE 'character_set_%'`、`SHOW VARIABLES LIKE 'collation_%'` 均返回 `Connection refused (os error 111)` - 当前 runner 复核:`examples/scripts/bootstrap-sqlite.sh`、`target/release/dbtool connect --driver sqlite ...`、`inspect --schema main`、`query --file examples/sql/sqlite/happy_path_query.sql` 再次通过 - 当前 runner 导出复核:已有文件时 `export` 明确拒绝覆盖;改用 `examples/tmp/qa-export-2.csv` 后导出 `4` 行成功,内容与 `EXPECTED_RESULTS.md` 一致 @@ -116,14 +128,12 @@ | --- | --- | --- | --- | --- | | 安装 / 构建 | 本地 Linux 已验证 | 待验证 | 待验证 | GitHub workflow 已配置,macOS / Windows runner 未在当前环境执行 | | CLI 启动 | 本地 Linux 已验证 | 待验证 | 待验证 | `--help` / `--version` 已纳入 release-smoke workflow | -| PostgreSQL | 待验证 | 待验证 | 待验证 | 需 Docker 环境完成真实 smoke | -| MySQL | 待验证 | 待验证 | 待验证 | 需 Docker 环境完成真实 smoke | +| PostgreSQL | 宿主机 Docker smoke 已验证 | 待验证 | 待验证 | 证据源为 [CMP-17](/CMP/issues/CMP-17#comment-0cd53e93-25a5-456b-8dba-31ea098c2aa8) | +| MySQL | 宿主机 Docker smoke 已验证 | 待验证 | 待验证 | happy path 与 Unicode patched-run 均已验证;当前剩余工作是跨平台 release 证据 | | SQLite | 本地 Linux 已验证 | 待验证 | 待验证 | 本地 query / export happy path 已验证 | | 导出流程 | 本地 Linux 已验证 | 待验证 | 待验证 | CSV / JSON 本地已验证,跨平台产物仍待 CI 实测 | ## 当前阻塞 -- 阻塞 1:当前 agent 环境没有 `docker` 可执行文件,因此无法在此处实测 PostgreSQL / MySQL 的 `connect` / `inspect` / `query` / `export` smoke。 -- 阻塞 2:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,因此 release artifact matrix 还缺跨平台实测证据。 -- 阻塞 3:当前 agent 环境也没有 `cargo`,因此即使迁入宿主机方案,仍需预编译二进制或额外准备 Rust toolchain。 -- 阻塞 4:发布 workflow 已落地,但仍需真实 CI 运行记录与 QA 证据来关闭发布前验收风险。 +- 阻塞 1:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,因此 release artifact matrix 还缺跨平台实测证据。 +- 阻塞 2:发布 workflow 已落地,但仍需真实 CI 运行记录与 QA 证据来关闭发布前验收风险。 diff --git a/CTO_REQUIREMENTS_HANDOFF.md b/CTO_REQUIREMENTS_HANDOFF.md new file mode 100644 index 0000000..aea0011 --- /dev/null +++ b/CTO_REQUIREMENTS_HANDOFF.md @@ -0,0 +1,251 @@ +# dbtool-cli-v1 CTO 需求交接说明 + +日期:2026-03-26 +作者:Product Manager + +## 1. 目的 + +这份文档是当前阶段给 CTO 的执行型交接说明,用来回答三件事: + +- 当前 demo 到底已经有什么 +- V1 到底要交付什么、不交付什么 +- CLI demo 应如何演进为更完整的产品流程,而不发生范围失控 + +如需查看完整背景,请同时参考: + +- `PRODUCT_REQUIREMENTS.md` +- `DATABASE_SUPPORT_MATRIX.md` +- `apps/tui/README.md` +- `gui/desktop-foundation.md` + +## 2. 当前 demo 能力盘点 + +截至 2026-03-26,仓库已经不是“空白项目”,而是一个可运行的跨数据库 demo: + +- `apps/cli` 已提供 `connect`、`inspect`、`query`、`export` 四条主命令 +- 当前支持 PostgreSQL、MySQL、SQLite +- 已支持 inline SQL 与 `.sql` 文件执行 +- 已支持参数化查询 +- 已支持导出 CSV / JSON +- 已支持 `--password-env` 注入密码,输出保持脱敏 +- 仓库内已有 smoke / release / QA 相关 runbook 和检查清单 + +同时,仓库已经出现两类“未来产品面”基础,但它们**不改变 CLI V1 首发范围**: + +- `crates/db-app`:共享应用编排层基础,说明产品对象已开始从 CLI 适配层抽离 +- `apps/tui`:终端工作台 shell baseline,当前只验证布局、焦点、状态和键盘路径 + +结论:当前阶段不是“发散功能想法”,而是“冻结首版产品边界,稳住三库共同主流程,并给后续界面演进留下统一对象模型”。 + +## 3. 目标用户 + +### 主用户 + +具备终端操作能力、但不想在不同数据库工具之间来回切换的技术操作型用户: + +- 后端工程师 +- 数据工程师 +- QA / 支持工程师 + +### 核心需求 + +用户需要一套一致的本地工具,在 PostgreSQL、MySQL、SQLite 之间稳定完成: + +1. 验证连接 +2. 查看结构 +3. 执行 SQL +4. 导出结果 + +当前产品卖点是**一致性与低切换成本**,不是“替代所有数据库管理工具”。 + +## 4. 最重要的用户场景 + +### 场景 A:线上/测试环境数据排查 + +后端或支持工程师要确认某个异常是不是数据问题: + +1. 指向 PostgreSQL 或 MySQL 目标 +2. 验证连接成功 +3. inspect schema / table / column +4. 执行针对性 SQL +5. 导出 JSON / CSV 作为问题证据 + +### 场景 B:本地 SQLite 文件验证 + +QA 拿到一个 SQLite 文件,需要快速确认内容: + +1. 指向本地文件 +2. 查看可用表 +3. 运行验证 SQL +4. 导出记录用于测试结论 + +如果以上场景不顺,V1 就不算完成。 + +## 5. V1 核心流程 + +当前必须围绕这条产品主路径定义与验收: + +`choose target -> connect -> inspect -> query -> export` + +细化为: + +1. 选择一个受支持的数据库目标 +2. 明确知道是否连通成功 +3. 定位 schema / table / column +4. 执行 inline SQL 或 `.sql` 文件 +5. 读取结果或执行摘要 +6. 在需要时导出为 CSV / JSON + +这条流程是 CLI、未来 TUI、未来 GUI 共享的产品骨架。 + +## 6. 当前阶段范围 + +### In Scope + +- 本地 CLI 首发面 +- PostgreSQL / MySQL / SQLite 三库 Tier A 核心闭环 +- 每次显式传入连接目标 +- schema / table / column inspect +- inline SQL 与 `.sql` 文件执行 +- 参数化查询 +- 结果导出到 CSV / JSON +- 清晰、可行动、且不泄露秘密的错误反馈 + +### 当前阶段产品边界 + +- “完整支持”指核心闭环完整,不指 DBA 全家桶完整 +- CLI 是当前正式交付面;TUI 和 GUI 只允许做后续演进准备 +- 优先保证一致性、正确性和失败路径,而不是追求快捷功能堆叠 + +## 7. 非目标 + +以下内容明确不进入当前 V1: + +- Desktop GUI 或 Web GUI 正式交付 +- 新数据库扩容到 PostgreSQL / MySQL / SQLite 之外 +- 用户可见的 saved profile 管理 +- import +- migration / schema editing 工作流 +- 事务控制 UX +- 存储过程 / function 管理 UX +- 权限 / 用户管理 +- 备份 / 恢复 +- 图表、dashboard、BI 视图 +- AI 助手、自动补全、Explain 可视化、复杂 query history +- 多连接并发会话与团队协作空间 + +## 8. 验收标准 + +### 全局 + +- 三库必须共享同一套操作心智:`connect`、`inspect`、`query`、`export` +- 失败必须返回非成功退出并给出可行动提示 +- 输出不得泄露密码 +- 数据库差异必须有记录,但不能破坏主流程 + +### Connect + +- 用户能提供目标类型与连接输入并得到明确成功/失败反馈 +- 认证错误、网络错误、不可达目标、SQLite 路径错误要可区分 +- 成功信息要能确认目标,但不泄露秘密 + +### Inspect + +- 用户能先看到 schema 或等价顶层对象 +- 用户能继续看到 table / view +- 用户能查看列名、类型、可空性以及主键信号 +- 空库或空 scope 必须有清晰反馈,而不是堆栈信息 + +### Query + +- 支持 inline SQL 与 `.sql` 文件 +- 有结果集时可读地输出列与行 +- 无结果集时给出执行摘要与影响行数 +- SQL 错误、权限失败、执行失败要有明确反馈 +- 空结果是成功,不是工具故障 + +### Export + +- 仅承诺导出结果集到 CSV / JSON +- 导出路径必须显式 +- 不允许静默覆盖已有文件 +- 导出结果的行数必须和查询结果一致 +- UTF-8 文本与多语言内容应保持稳定 + +## 9. CLI demo 如何向产品界面 / 产品流程演进 + +### 演进原则 + +- 不另造一套产品语言 +- 不借界面开发偷偷扩 scope +- 先稳定共享对象,再增加交互层 + +### 建议演进顺序 + +#### 阶段 1:稳住 CLI 产品语义 + +目标: + +- 固定 `connect / inspect / query / export` 的产品对象 +- 固定空结果、失败、导出成功等标准状态 +- 保证 README、帮助文案、QA 验收与实际行为一致 + +#### 阶段 2:以共享应用层承接跨界面复用 + +目标: + +- 继续把 CLI adapter 中的编排职责收敛到 `crates/db-app` +- 让连接摘要、inspect payload、query result、export result 变成可复用对象 +- 避免未来 TUI / GUI 重新定义结果结构 + +#### 阶段 3:TUI 作为流程承载层验证工作台形态 + +当前 TUI 只应承接: + +- 单连接工作台 +- schema browser +- query editor 容器 +- results / inspector / status + +它是产品流程验证层,不是 V1 CLI 发布阻塞项,也不是新功能扩张入口。 + +#### 阶段 4:未来桌面 GUI 复用同一对象模型 + +未来 GUI 应直接映射以下稳定对象: + +- `connect` -> Connection Manager / Test Connection +- `inspect` -> Schema Browser / Object Inspector +- `query` -> Query Editor / Run Action +- `export` -> Export Flow / Export Feedback + +GUI 的职责是提升承载方式,不是改写产品流程。 + +## 10. 对 CTO 的拆解要求 + +CTO 拆任务时,请围绕以下边界: + +### 必须优先拆的 + +- 三库核心闭环质量收口 +- 失败路径与错误可读性收口 +- 文档 / 帮助 / QA 验收一致性 +- `db-app` 共享对象与执行结果的稳定化 + +### 不应混入当前阶段的 + +- 新数据库接入 +- 管理面能力扩张 +- 为 GUI/TUI 预埋超出 V1 的高级功能 +- 把原始 SQL 执行误扩成 migration / admin 产品 + +### CTO 仍可自主决定的实现项 + +- 命令命名与 flag 细节 +- crate 边界与内部结构 +- 连接配置落地方式 +- 输出渲染方式 +- 打包与发布实现 + +## 11. 一句话交付定义 + +`dbtool-cli-v1` 首发完成的标准是:**PostgreSQL、MySQL、SQLite 三库都能稳定完成 `connect -> inspect -> query -> export` 核心闭环,且没有被 GUI/TUI 或高级数据库管理能力带偏范围。** diff --git a/DATABASE_SUPPORT_MATRIX.md b/DATABASE_SUPPORT_MATRIX.md new file mode 100644 index 0000000..3c7b240 --- /dev/null +++ b/DATABASE_SUPPORT_MATRIX.md @@ -0,0 +1,184 @@ +# dbtool-cli-v1 Database Support Matrix + +日期:2026-03-26 +作者:Product Manager +对应 issue:`CMP-21` + +## 1. 决策摘要 + +- 当前项目不承诺“所有数据库的完整功能”,只承诺一个可验证的跨数据库核心操作闭环。 +- 对本产品而言,“完整核心支持”指的是:用户可以稳定完成 `connect -> inspect -> query -> export`,并获得一致的安全边界、错误反馈和结果语义。 +- PostgreSQL、MySQL、SQLite 都是当前项目的 **Tier A 目标数据库**,但只针对核心操作闭环,不延伸到数据库管理全家桶。 +- 保存连接 profile、导入、migration、事务控制、存储过程 / function 管理、权限 / 用户管理、备份 / 恢复都不属于当前项目承诺。 +- 下一批数据库只保留候选池,不进入当前项目承诺;是否进入下一阶段,取决于当前三库的 Tier A 质量是否稳定。 + +## 2. 支持等级定义 + +### Tier A:完整核心支持 + +- 属于当前项目明确承诺 +- 必须进入 README、帮助文案、QA 验收和未来发布判断 +- 必须在产品语义上跨数据库尽量一致 +- 若存在数据库差异,必须明确记录且不破坏主流程 + +### Tier B:有限支持 + +- 不是当前版本必须交付的基线 +- 只在下一阶段被明确立项后才进入承诺 +- 可以先有数据库差异化设计,但不能反向定义当前版本范围 + +### Tier C:候选 / 实验 / 不承诺 + +- 当前项目不承诺交付 +- 可以保留为研究方向、候选数据库或后续需求池 +- 不进入当前版本完成标准 + +## 3. 产品语义:什么叫“完整功能” + +本项目里的“完整功能”不是: + +- 全量数据库管理功能 +- 替代 DBeaver、DataGrip、pgAdmin、MySQL Workbench +- 覆盖 DBA 全工作流 + +本项目里的“完整核心支持”是: + +1. 能明确选择目标数据库 +2. 能验证连接 +3. 能定位 schema / table / column +4. 能执行 SQL +5. 能执行参数化查询 +6. 能把结果导出为 CSV / JSON +7. 能在失败时给出可行动提示,且不泄露密码 + +## 4. 当前阶段数据库等级 + +| 数据库 | 当前阶段目标等级 | 当前阶段定位 | 备注 | +| --- | --- | --- | --- | +| PostgreSQL | Tier A | 核心服务器数据库 | 当前 demo 已覆盖核心 happy path | +| MySQL | Tier A | 核心服务器数据库 | 当前 demo 已覆盖核心 happy path | +| SQLite | Tier A | 核心本地文件数据库 | 当前 demo 已覆盖核心 happy path,但允许文件型语义差异 | + +结论:**当前项目不是“先做 PostgreSQL,其他顺带”;而是三种数据库都要对核心闭环负责。** + +## 5. 能力矩阵 + +| 能力维度 | PostgreSQL | MySQL | SQLite | 当前项目要求 | 说明 | +| --- | --- | --- | --- | --- | --- | +| 连接(ad hoc) | Tier A | Tier A | Tier A | 必须统一 | 当前命令模型已经存在 | +| 连接 profile 管理 | Tier C | Tier C | Tier C | 当前不做 | 内部有 profile 概念,不等于用户可保存 / 列表化 | +| schema / table / column introspection | Tier A | Tier A | Tier A | 必须统一心智 | 允许 schema 语义按数据库差异映射 | +| query 执行 | Tier A | Tier A | Tier A | 必须统一 | 支持 inline SQL 与 `.sql` 文件 | +| 参数化查询 | Tier A | Tier A | Tier A | 必须统一 | 作为核心安全与复用能力保留 | +| 导出 CSV / JSON | Tier A | Tier A | Tier A | 必须统一 | 只承诺结果集导出 | +| 导入 | Tier C | Tier C | Tier C | 当前不做 | 不进入当前项目完成标准 | +| migration / script 执行 | Tier C | Tier C | Tier C | 当前不做 | 原始 SQL 文件执行不等于产品化 migration 工作流 | +| 事务控制 | Tier C | Tier C | Tier C | 当前不做 | 不为本期提供专门 UX 或契约 | +| 存储过程 / function | Tier C | Tier C | Tier C | 当前不做 | 可通过普通查询间接触达,但不承诺专门支持 | +| 权限与用户管理 | Tier C | Tier C | Tier C | 当前不做 | 属于管理面,易导致范围失控 | +| 备份 / 恢复 | Tier C | Tier C | Tier C | 当前不做 | 属于运维 / DBA 面,不进入当前项目 | + +## 6. 哪些能力必须统一,哪些允许差异 + +### 必须统一支持 + +- 命令入口仍围绕 `connect`、`inspect`、`query`、`export` +- 成功 / 失败都必须给出稳定的终端反馈 +- 参数化查询属于核心能力,不允许只在单一数据库成立 +- 导出格式只承诺 CSV / JSON +- 密码只能经安全输入路径注入,输出不得泄露密码 + +### 允许数据库差异化 + +- 顶层 inspect 对象的命名和映射方式 +- schema 概念的具体落点 +- 多语句 SQL 的执行限制 +- 返回元数据的丰富程度 +- 底层驱动报错文本细节 + +### 当前已知差异(允许保留) + +- MySQL 的 `--schema` 当前映射到数据库名 +- SQLite 使用文件路径连接,不使用 host / port +- SQLite demo inspect 使用 `main` 作为 schema +- PostgreSQL / MySQL 非参数化 query 可以执行多语句 SQL 文件 +- SQLite 当前按单条 statement 执行,多语句输入会显式失败 + +## 7. 当前阶段范围与非目标 + +### 当前阶段范围 + +- 把 PostgreSQL / MySQL / SQLite 的核心闭环做实并做稳 +- 固化跨数据库一致的产品语义 +- 用共享矩阵约束 CTO 的拆解边界 +- 让 QA 能按数据库分层建立未来验收策略 + +### 当前阶段非目标 + +- 扩库到更多数据库 +- 为单一数据库补一堆高级能力 +- 把原始 SQL 执行能力包装成 migration 产品 +- 进入 GUI 正式开发 +- 承诺数据库管理平台式能力 + +## 8. 下一批候选数据库 + +这些数据库可进入下一阶段候选池,但**不进入当前项目承诺**: + +| 候选数据库 | 保留原因 | 最早讨论时点 | +| --- | --- | --- | +| SQL Server | 企业环境常见,和当前目标用户重叠 | 当前三库 Tier A 稳定后 | +| MariaDB | 与 MySQL 用户群接近,可验证兼容价值 | 当前 MySQL 质量收敛后 | +| DuckDB | 与 SQLite 一样适合本地文件 / 分析型场景 | 当前文件型流程稳定后 | + +当前不建议把 Oracle、MongoDB、Redis 等直接放入下一批: + +- Oracle:产品与合规复杂度高 +- MongoDB:数据模型差异过大,会改写当前统一心智 +- Redis:不是当前 SQL / schema / export 产品语义的自然延伸 + +## 9. CLI Demo 如何向产品界面 / 产品流程演进 + +CLI demo 当前已经验证了未来产品最重要的五个对象: + +- connection target +- catalog / schema browser state +- query request +- result set +- export action + +未来 GUI 或更完整产品流程应直接映射这些对象,而不是另起一套产品语言: + +- `connect` -> Connection Manager / Test Connection +- `inspect` -> Schema Browser / Object Inspector +- `query` -> Query Editor / Run Action +- `export` -> Export Drawer / Export Feedback + +演进原则: + +- 先复用 CLI 已稳定的产品语义 +- 再增加界面层承载 +- 不在 GUI 中偷偷扩充当前版本未承诺的能力 + +## 10. 给 CTO 和 QA 的交接说明 + +### 给 CTO + +- 当前项目的拆解中心应是“三库同一核心闭环”,不是“继续扩命令面” +- Tier A 项必须优先保证一致性、失败路径和可发布性 +- Tier C 项不要被拆成当前项目 issue,除非先有新的产品决策 + +### 给 QA + +- 未来验收策略应先按 Tier A 能力建立主矩阵 +- 同一能力要按 PostgreSQL / MySQL / SQLite 分库验证 +- 差异项要记录,但不应把已允许的数据库差异误判成产品缺陷 + +## 11. 阶段通过标准 + +当前阶段可以认为产品边界清晰,当且仅当: + +- PostgreSQL / MySQL / SQLite 的 Tier A 范围被团队共同接受 +- Tier C 能力没有混入当前项目承诺 +- CTO 可以据此继续拆任务,而不再重新解释“完整支持”是什么意思 +- QA 可以据此建立分数据库验收矩阵 diff --git a/DBTOOL_USABLE_V1_PRODUCT_BRIEF.md b/DBTOOL_USABLE_V1_PRODUCT_BRIEF.md new file mode 100644 index 0000000..706f622 --- /dev/null +++ b/DBTOOL_USABLE_V1_PRODUCT_BRIEF.md @@ -0,0 +1,224 @@ +# dbtool-usable-v1 Product Brief + +日期:2026-03-27 +作者:Product Manager + +## 1. 文档目的 + +这份文档用于冻结 `dbtool-usable-v1` 的当前产品边界,回答四个问题: + +- 现有 demo 已经具备什么能力 +- 当前版本的真实用户是谁 +- “usable-v1” 在本阶段到底意味着什么 +- CLI demo 应如何演进到更完整的产品界面与产品流程 + +配套控制文档: + +- `PRODUCT_REQUIREMENTS.md` +- `DATABASE_SUPPORT_MATRIX.md` +- `CTO_REQUIREMENTS_HANDOFF.md` +- `ACCEPTANCE_CHECKLIST.md` + +## 2. 当前 demo 已有能力 + +截至 2026-03-27,仓库已经具备以下用户可见基线: + +- `dbtool` CLI 已提供 `connect`、`inspect`、`query`、`export` +- PostgreSQL、MySQL、SQLite 都已有核心 happy path +- 支持 inline SQL、`.sql` 文件、参数化查询 +- 支持导出 CSV / JSON +- 支持 `--password-env` 注入密码,输出保持脱敏 +- 仓库已包含 demo fixtures、smoke runbook、release runbook 和 QA 清单 + +同时,仓库内已出现两类后续产品演进基础,但它们**不改变 usable-v1 当前交付面**: + +- `crates/db-app`:CLI / TUI 可复用的共享应用层 +- `apps/tui` 与 `gui/`:工作台形态与信息架构验证资产 + +结论:当前不是“从零定义产品”,而是“把已存在 demo 收敛成明确可交付边界”。 + +## 3. 目标用户 + +### 主用户 + +第一阶段主用户是熟悉终端的技术操作型用户: + +- 后端工程师 +- 数据工程师 +- QA 工程师 +- 支持 / 运维排障人员 + +### 不优先服务的用户 + +- 需要图形化数据库管理台的非终端用户 +- 需要完整 DBA 能力的高级数据库管理员 +- 需要团队共享工作台、权限编排或云端协作的组织型用户 + +## 4. 真实使用场景 + +### 场景 A:跨库排障 + +用户需要在 PostgreSQL 或 MySQL 中快速确认某个异常是否由数据导致: + +1. 指向目标数据库 +2. 验证能否连通 +3. 查看 schema / table / column +4. 执行验证 SQL +5. 导出结果作为问题证据 + +### 场景 B:SQLite 本地验证 + +用户拿到一个 SQLite 文件,需要快速核对数据内容: + +1. 指向本地文件 +2. 查看可用对象 +3. 运行查询 +4. 导出结果给 QA 或研发 + +### 场景 C:统一心智替代多工具切换 + +用户不想在 `psql`、MySQL 工具链和 SQLite 工具之间切换,希望用一套统一命令完成最常见操作。 + +## 5. 最重要的产品流程 + +usable-v1 的核心流程固定为: + +`choose target -> connect -> inspect -> query -> export` + +如果这条路径不能在三种目标数据库上稳定成立,则当前阶段不能称为“真实可用”。 + +## 6. usable-v1 定义 + +本阶段“真实可用”不是指: + +- 支持所有数据库 +- 覆盖所有数据库管理能力 +- 提供完整 GUI / IDE 体验 + +本阶段“真实可用”指: + +- 用户能在本地 CLI 中稳定完成 `connect -> inspect -> query -> export` +- PostgreSQL、MySQL、SQLite 三库都达到同一核心闭环标准 +- 失败路径可理解、可行动、且不会泄露密码 +- README、帮助文案、QA 验收和实际行为一致 + +## 7. 当前阶段范围 + +### In Scope + +- 本地 CLI 作为正式交付面 +- PostgreSQL / MySQL / SQLite 三库核心闭环 +- ad hoc 连接输入 +- schema / table / column inspect +- inline SQL 与 `.sql` 文件执行 +- 参数化查询 +- 结果导出到 CSV / JSON +- 清晰的执行结果与错误反馈 + +### Non-Goals + +- GUI 正式交付 +- 新数据库扩容 +- saved profile 用户工作流 +- import、migration、schema editing +- transaction UX +- stored procedure / function 管理 +- 权限 / 用户管理 +- backup / restore +- dashboard、BI、AI 助手、复杂 IDE 能力 +- 团队协作空间或云端同步 + +## 8. 数据库支持边界 + +当前数据库支持等级以 `DATABASE_SUPPORT_MATRIX.md` 为准,摘要如下: + +| 数据库 | 当前等级 | usable-v1 必须做到 | +| --- | --- | --- | +| PostgreSQL | Tier A | connect、inspect、query、export 核心闭环可用 | +| MySQL | Tier A | connect、inspect、query、export 核心闭环可用 | +| SQLite | Tier A | connect、inspect、query、export 核心闭环可用 | + +补充边界: + +- Tier A 只代表核心闭环完整,不代表数据库管理全功能 +- PostgreSQL / MySQL / SQLite 都是当前承诺,不是“先做一库,其他顺带” +- SQL Server、MariaDB、DuckDB 仅保留为后续候选 + +## 9. usable-v1 验收标准 + +### 全局标准 + +- 三库共享同一套产品心智:`connect`、`inspect`、`query`、`export` +- 所有失败都必须返回非成功退出并给出可行动提示 +- 所有输出都不得泄露密码或明文秘密 +- 数据库差异必须被文档化,但不能破坏主流程 + +### Connect + +- 用户可显式提供目标数据库和连接输入 +- 成功时能确认目标已连接 +- 失败时能区分认证、网络、不可达目标和 SQLite 路径问题 + +### Inspect + +- 用户可查看 schema 或等价顶层对象 +- 用户可继续查看 table / view +- 用户可查看列名、类型、可空性与主键信号 +- 空 scope 需给出清晰反馈 + +### Query + +- 支持 inline SQL 与 `.sql` 文件 +- 有结果集时输出可读表格或结构化结果 +- 无结果集时提供执行摘要 +- 空结果是成功,不是故障 +- SQL / 权限 / 驱动错误要明确 + +### Export + +- 只承诺结果集导出到 CSV / JSON +- 导出路径必须显式 +- 不允许静默覆盖已有文件 +- 导出成功与失败都必须可判断 + +## 10. CLI demo 如何演进为产品界面 / 产品流程 + +### 阶段 1:稳定 CLI 产品语义 + +- 固定 `connect / inspect / query / export` 的产品对象 +- 固定 `ready / success / empty / error / exported` 等用户可感知状态 +- 保证文档、帮助和行为一致 + +### 阶段 2:共享应用层复用 + +- 继续把编排逻辑收敛到 `crates/db-app` +- 让连接、inspect、query、export 输出成为跨界面复用对象 + +### 阶段 3:TUI 验证工作台流程 + +- 用 TUI 验证单连接工作台、schema browser、query editor、results、status +- TUI 是流程承载层,不是当前 CLI 范围扩张入口 + +### 阶段 4:未来 GUI 复用同一对象模型 + +- GUI 只是在更强承载层上复用既有产品语义 +- 不应借 GUI 开发把范围扩张到 migration、管理面或 BI + +## 11. 给 CTO 的交接边界 + +CTO 拆任务时应优先围绕: + +- 三库核心闭环质量收口 +- 错误反馈和失败路径可读性 +- README / 帮助 / QA 验收一致性 +- 共享应用层对象稳定化 + +CTO 不应在当前阶段混入: + +- 新数据库扩容 +- 高级数据库管理能力 +- 为未来 GUI/TUI 预埋超出本期的产品承诺 + +## 12. 一句话定义 + +`dbtool-usable-v1` 的完成标准是:**PostgreSQL、MySQL、SQLite 三库都能以一致心智稳定跑通 `connect -> inspect -> query -> export`,且范围没有扩张到 GUI 正式交付或高级数据库管理能力。** diff --git a/DEVELOPING.md b/DEVELOPING.md index 255f952..68643b7 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -8,20 +8,53 @@ cargo test cargo run -p dbtool-cli -- --help ``` +TUI 开发验证补充: + +```bash +cargo build -p dbtool-tui +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.log +``` + +`dbtool-tui` 需要 TTY;不要用 `cargo run -p dbtool-tui -- --help` 替代交互 smoke。详细口径见 `TUI_SMOKE_RUNBOOK.md`。 + Git 仓库、branch、commit、push 与 agent 认证规范见 `GIT_WORKFLOW.md`。 -如果当前 Linux 环境没有 `cc`,改用一个 C 兼容 linker,例如 `zig cc`: +如果当前 Linux 环境没有 `cc` / `ar`,改用 `zig cc` + `zig ar`。对 bundled SQLite 路径,linker 包装器需要兼容 Cargo 传入的 GNU target flag: ```bash cat > /tmp/zig-cc <<'EOF' -#!/bin/sh -exec zig cc "$@" +#!/usr/bin/env bash +set -euo pipefail +args=() +for arg in "$@"; do + case "$arg" in + --target=x86_64-unknown-linux-gnu) + args+=("--target=x86_64-linux-gnu") + ;; + *) + args+=("$arg") + ;; + esac +done +exec zig cc "${args[@]}" EOF -chmod +x /tmp/zig-cc +cat > /tmp/zig-ar <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec zig ar "$@" +EOF +chmod +x /tmp/zig-cc /tmp/zig-ar export CC=/tmp/zig-cc +export AR=/tmp/zig-ar export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/tmp/zig-cc -cargo test -cargo run -p dbtool-cli -- --help + +# 如果当前环境的 rustup 没有默认 toolchain,可显式指定 stable 工具链 +export RUSTC="$(rustup which --toolchain stable rustc)" +export RUSTDOC="$(rustup which --toolchain stable rustdoc)" +"$(dirname "$RUSTC")/cargo" test --workspace +"$(dirname "$RUSTC")/cargo" build --release --bin dbtool +./target/release/dbtool --help ``` ## 当前 crate 责任 @@ -54,7 +87,7 @@ cargo run -p dbtool-cli -- --help - `cargo run -p dbtool-cli -- query ... --file ` 验证 SQL 文件读取入口 - `cargo run -p dbtool-cli -- export ... --format --output ` 验证导出路径 - `./examples/scripts/bootstrap-sqlite.sh` + SQLite CLI happy path 验证本地样例数据库闭环 -- 无系统 linker 的 Linux 环境使用 `zig cc` 作为 host linker 验证路径 +- 无系统 linker 的 Linux 环境使用兼容 Cargo target flag 的 `zig cc` 包装器与 `zig ar` 验证路径 ## 发布 smoke diff --git a/FAILURE_PATH_EVIDENCE_SAMPLE.md b/FAILURE_PATH_EVIDENCE_SAMPLE.md new file mode 100644 index 0000000..4473fc4 --- /dev/null +++ b/FAILURE_PATH_EVIDENCE_SAMPLE.md @@ -0,0 +1,150 @@ +# PostgreSQL / MySQL Failure-Path Evidence Sample + +日期:2026-03-28 +作者:Senior Backend Engineer +用途:给 QA / CTO 一份可直接复制改值的 failure-path 记录样例。 + +## 使用方式 + +- 本文件是“填写示例”,不是执行证据本身。 +- 只有把其中的时间、宿主机、二进制路径、commit、命令输出替换为真实值后,才能回填到 `USABLE_EVIDENCE_LEDGER.md` 或 issue 评论中。 +- 正式场景列表与命令来源仍以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准。 + +## Sample A: PostgreSQL Wrong Password + +### `PG-CONN-AUTH-001` + +- Date (UTC): `2026-03-28T14:10:00Z` +- Operator: `qa-engineer` +- Host: `qa-host-01` +- Binary Path: `./target/release/dbtool` +- Binary Version: `0.1.0` +- Commit SHA: `` +- Branch: `` +- Driver: `postgres` +- Scenario ID: `PG-CONN-AUTH-001` +- Command: + +```bash +export DBTOOL_PASSWORD=wrong-password +./target/release/dbtool connect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Exit Code: `2` + +Output (excerpt): + +```json +{ + "status": "error", + "operation": "connect", + "state": "error", + "error": { + "operation": "connect", + "kind": "authentication", + "message": "postgres authentication failed" + } +} +``` + +Verdict: + +- Expected: `status=error`, `state=error`, `error.kind=authentication`, 无密码泄露 +- Actual: JSON envelope 返回 `authentication`,未回显 password env 的实际值 +- Result: `pass` + +## Sample B: MySQL Unreachable Port + +### `MY-CONN-NET-001` + +- Date (UTC): `2026-03-28T14:18:00Z` +- Operator: `qa-engineer` +- Host: `qa-host-01` +- Binary Path: `./target/release/dbtool` +- Binary Version: `0.1.0` +- Commit SHA: `` +- Branch: `` +- Driver: `mysql` +- Scenario ID: `MY-CONN-NET-001` +- Command: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool connect --driver mysql --host 127.0.0.1 --port 59998 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Exit Code: `2` + +Output (excerpt): + +```json +{ + "status": "error", + "operation": "connect", + "state": "error", + "error": { + "operation": "connect", + "kind": "connection", + "message": "connection failed: Connection refused (os error 111)" + } +} +``` + +Verdict: + +- Expected: `status=error`, `state=error`, `error.kind=connection` +- Actual: JSON envelope 返回 `connection`,符合不可达端口预期 +- Result: `pass` + +## Sample C: PostgreSQL Bad SQL + +### `PG-QUERY-SQL-001` + +- Date (UTC): `2026-03-28T14:25:00Z` +- Operator: `qa-engineer` +- Host: `qa-host-01` +- Binary Path: `./target/release/dbtool` +- Binary Version: `0.1.0` +- Commit SHA: `` +- Branch: `` +- Driver: `postgres` +- Scenario ID: `PG-QUERY-SQL-001` +- Command: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool query --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/postgres/failing_query.sql --result-format json +``` + +- Exit Code: `2` + +Output (excerpt): + +```json +{ + "status": "error", + "operation": "query", + "state": "error", + "error": { + "operation": "query", + "kind": "query", + "message": "query failed: relation does not exist" + } +} +``` + +Verdict: + +- Expected: `status=error`, `state=error`, `error.kind=query` +- Actual: JSON envelope 返回 `query`,错误定位在 SQL 执行阶段 +- Result: `pass` + +## 回填规则 + +- 一次执行至少保留: + - 完整命令 + - 退出码 + - 关键 JSON 片段 + - 实际 commit / branch +- 若输出和样例文案不同,只要 `status`、`state`、`error.kind` 与场景预期一致,可以判为 `pass`。 +- 若 `error.kind` 偏离预期,应优先回填到 issue,并视为 backend contract 回归候选。 diff --git a/FAILURE_PATH_EVIDENCE_TEMPLATE.md b/FAILURE_PATH_EVIDENCE_TEMPLATE.md new file mode 100644 index 0000000..b2f6d38 --- /dev/null +++ b/FAILURE_PATH_EVIDENCE_TEMPLATE.md @@ -0,0 +1,196 @@ +# PostgreSQL / MySQL Failure-Path Evidence Template + +日期:2026-03-28 +作者:Senior Backend Engineer +用途:`dbtool-usable-v1` 在 PostgreSQL / MySQL failure-path 上补齐可审计证据,供 QA / CTO 直接复用。 + +配套样例:`FAILURE_PATH_EVIDENCE_SAMPLE.md` + +## 1. 适用范围 + +- 当前仅覆盖 `connect`、`query` 的 failure-path。 +- 目标是把 release gate 中“PostgreSQL / MySQL failure-path 仍缺可审计执行证据”这条 blocker 收口为可执行清单。 +- 不扩展产品范围,不引入新能力,不替代 `SMOKE_RUNBOOK.md` 的 happy-path 验证。 + +## 2. 执行前置 + +- 执行环境:Docker-capable 宿主机(当前 QA runner 仍缺 `docker`)。 +- 已完成: + - `docker compose -f docker-compose.demo.yml up -d postgres mysql` + - `./examples/scripts/bootstrap-postgres.sh` + - `./examples/scripts/bootstrap-mysql.sh` +- CLI 可执行: + - 推荐 `./target/release/dbtool` + - 或 `cargo run -q -p dbtool-cli -- ` +- 若想减少人工整理,可直接运行: + +```bash +scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failure-path-evidence +``` + +脚本会为 6 个场景分别生成 `.out` 与 `.md` 证据文件,便于直接贴回 issue 评论。 +当前 helper 还会把可检测到的密码泄露模式直接判成 `fail`: + +- 错误密码场景:对原始密码值做精确匹配 +- 真实密码场景:检查常见泄露模式,例如 `password=`、`"password":""`、`:@` +- 若 `Binary Version`、`Commit SHA`、`Branch`、`Host` 等 traceability 字段缺失或退化为 `unknown-*`,helper 也会直接判成 `fail` + +若执行环境不是宿主机直连,而是 sidecar container / 自定义网络路径,可覆盖默认 host / port: + +```bash +DBTOOL_POSTGRES_HOST=postgres \ +DBTOOL_POSTGRES_PORT=5432 \ +DBTOOL_MYSQL_HOST=mysql \ +DBTOOL_MYSQL_PORT=3306 \ +scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failure-path-evidence +``` + +若当前 QA runner 只能通过 Docker daemon + sidecar 网络接入数据库,也可直接运行: + +```bash +scripts/qa/run-failure-path-evidence-sidecar.sh ./target/release/dbtool ./tmp/failure-path-evidence +``` + +该脚本会: + +- 重新导入 PostgreSQL / MySQL demo 数据 +- 创建 sidecar container 加入 `dbtool-cli-v1_default` +- 在 sidecar 内调用 `scripts/qa/run-failure-path-evidence.sh` +- 将生成的 `.out` / `.md` 证据包复制回本地输出目录 +- 继承宿主发起侧的 host / binary / version / commit / branch 标签,避免 sidecar 与 host-run 两条路径出现双口径 traceability + +## 3. 追溯字段(每条证据都要填) + +- Date (UTC): +- Operator: +- Host: +- Execution Path: +- Binary Path: +- Binary Version (`--version`): +- Commit SHA: +- Branch: +- Driver: +- Scenario ID: +- Command: +- Exit Code: +- Key Output: +- Verdict (`pass` / `fail`): + +说明: + +- helper 当前会自动生成并校验这些字段中的大部分内容。 +- 若生成结果出现 `unknown-version`、`unknown-commit`、`unknown-branch`、`unknown-host` 或空值,应视为 traceability 不完整。 +- `Execution Path` 推荐使用: + - `host` + - `sidecar` + +## 4. 场景矩阵 + +| Scenario ID | Driver | Command | Failure Type | JSON Expectation | +| --- | --- | --- | --- | --- | +| `PG-CONN-AUTH-001` | postgres | `connect` | 错误密码 | `status=error`, `state=error`, `error.kind=authentication` | +| `PG-CONN-NET-001` | postgres | `connect` | 不可达端口 | `status=error`, `state=error`, `error.kind=connection` | +| `PG-QUERY-SQL-001` | postgres | `query` | 坏 SQL / 不存在对象 | `status=error`, `state=error`, `error.kind=query` | +| `MY-CONN-AUTH-001` | mysql | `connect` | 错误密码 | `status=error`, `state=error`, `error.kind=authentication` | +| `MY-CONN-NET-001` | mysql | `connect` | 不可达端口 | `status=error`, `state=error`, `error.kind=connection` | +| `MY-QUERY-SQL-001` | mysql | `query` | 坏 SQL / 不存在对象 | `status=error`, `state=error`, `error.kind=query` | + +说明: + +- `status` 是 envelope 级结果(`success` / `error`)。 +- `state` 是共享状态映射(失败路径应为 `error`)。 + +## 5. 命令模板 + +先设置正确密码,再按场景覆盖: + +```bash +export DBTOOL_PASSWORD=dbtool +``` + +### PostgreSQL + +错误密码: + +```bash +export DBTOOL_PASSWORD=wrong-password +./target/release/dbtool connect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +网络不可达: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool connect --driver postgres --host 127.0.0.1 --port 59999 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +坏 SQL: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool query --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/postgres/failing_query.sql --result-format json +``` + +### MySQL + +错误密码: + +```bash +export DBTOOL_PASSWORD=wrong-password +./target/release/dbtool connect --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +网络不可达: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool connect --driver mysql --host 127.0.0.1 --port 59998 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +坏 SQL: + +```bash +export DBTOOL_PASSWORD=dbtool +./target/release/dbtool query --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/mysql/failing_query.sql --result-format json +``` + +## 6. 证据记录模板 + +````md +### + +- Date (UTC): +- Operator: +- Host: +- Binary Path: +- Binary Version: +- Commit SHA: +- Branch: +- Command: +- Exit Code: + +Output (excerpt): +```json +{ + "status": "error", + "operation": "connect", + "state": "error", + "error": { + "kind": "authentication" + } +} +``` + +Verdict: +- Expected: +- Actual: +- Result: pass | fail +```` + +## 7. 收口标准 + +- 上述 6 个场景每个至少一条可审计记录。 +- 每条记录都包含追溯字段和命令输出摘要。 +- `USABLE_EVIDENCE_LEDGER.md` 的 PostgreSQL / MySQL failure-path 行更新为 `verified` 或明确保留 `blocked` 原因。 +- `USABLE_RELEASE_GATE.md` 的对应 blocker 条目同步更新。 +- 若使用 `scripts/qa/run-failure-path-evidence.sh`,仍需人工确认生成的 `Result`、traceability 字段与场景归类是否与实际执行一致,再回填到共享台账。 diff --git a/HOST_FAILURE_PATH_CHECKLIST.md b/HOST_FAILURE_PATH_CHECKLIST.md new file mode 100644 index 0000000..691a873 --- /dev/null +++ b/HOST_FAILURE_PATH_CHECKLIST.md @@ -0,0 +1,201 @@ +# PostgreSQL / MySQL Host Failure-Path Checklist + +日期:2026-03-28 +作者:Senior Backend Engineer +用途:给 QA / CTO 一份可直接执行的宿主机检查清单,用最短路径补齐 PostgreSQL / MySQL failure-path 可审计证据。 + +配套文档: + +- `QA_RUNTIME_ENVIRONMENT.md` +- `FAILURE_PATH_EVIDENCE_TEMPLATE.md` +- `FAILURE_PATH_EVIDENCE_SAMPLE.md` +- `USABLE_EVIDENCE_LEDGER.md` +- `USABLE_RELEASE_GATE.md` + +## 1. 适用边界 + +- 当前只覆盖 CLI 的 `connect` / `query` failure-path。 +- 当前不替代 `SMOKE_RUNBOOK.md` 的 happy-path。 +- 当前目标是把宿主机执行步骤、预期断言和回填顺序收敛成一份可执行 runbook。 + +## 2. 前置条件 + +- 宿主机具备 `docker` 与 `docker compose` +- 已拉取当前仓库并进入项目根目录 +- 已准备可执行 CLI: + - `./target/release/dbtool` + - 或 `./target/debug/dbtool` + - 或 `cargo run -q -p dbtool-cli -- ` +- 宿主机可记录: + - UTC 时间 + - binary path + - `dbtool --version` + - commit SHA / branch + +## 3. 执行前准备 + +### 3.1 启动数据库 + +```bash +docker compose -f docker-compose.demo.yml up -d postgres mysql +./examples/scripts/bootstrap-postgres.sh +./examples/scripts/bootstrap-mysql.sh +``` + +### 3.2 固定 CLI 路径 + +```bash +export DBTOOL_BIN=./target/release/dbtool +test -x "$DBTOOL_BIN" +"$DBTOOL_BIN" --version +git rev-parse HEAD +git branch --show-current +``` + +若当前只构建了 debug binary,可改成: + +```bash +export DBTOOL_BIN=./target/debug/dbtool +``` + +### 3.3 固定密码环境变量 + +```bash +export DBTOOL_PASSWORD=dbtool +``` + +## 4. 统一断言规则 + +每条场景都至少检查: + +- `status` +- `state` +- `error.kind` +- 输出中不泄露真实密码 + +| Scenario ID | Driver | 失败类型 | 预期 | +| --- | --- | --- | --- | +| `PG-CONN-AUTH-001` | postgres | 错误密码 | `status=error`, `state=error`, `error.kind=authentication` | +| `PG-CONN-NET-001` | postgres | 不可达端口 | `status=error`, `state=error`, `error.kind=connection` | +| `PG-QUERY-SQL-001` | postgres | 坏 SQL / 不存在对象 | `status=error`, `state=error`, `error.kind=query` | +| `MY-CONN-AUTH-001` | mysql | 错误密码 | `status=error`, `state=error`, `error.kind=authentication` | +| `MY-CONN-NET-001` | mysql | 不可达端口 | `status=error`, `state=error`, `error.kind=connection` | +| `MY-QUERY-SQL-001` | mysql | 坏 SQL / 不存在对象 | `status=error`, `state=error`, `error.kind=query` | + +## 5. 执行顺序 + +### 5.1 PostgreSQL 错误密码 + +```bash +export DBTOOL_PASSWORD=wrong-password +"$DBTOOL_BIN" connect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Scenario ID:`PG-CONN-AUTH-001` + +### 5.2 PostgreSQL 不可达端口 + +```bash +export DBTOOL_PASSWORD=dbtool +"$DBTOOL_BIN" connect --driver postgres --host 127.0.0.1 --port 59999 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Scenario ID:`PG-CONN-NET-001` + +### 5.3 PostgreSQL 坏 SQL + +```bash +export DBTOOL_PASSWORD=dbtool +"$DBTOOL_BIN" query --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/postgres/failing_query.sql --result-format json +``` + +- Scenario ID:`PG-QUERY-SQL-001` + +### 5.4 MySQL 错误密码 + +```bash +export DBTOOL_PASSWORD=wrong-password +"$DBTOOL_BIN" connect --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Scenario ID:`MY-CONN-AUTH-001` + +### 5.5 MySQL 不可达端口 + +```bash +export DBTOOL_PASSWORD=dbtool +"$DBTOOL_BIN" connect --driver mysql --host 127.0.0.1 --port 59998 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json +``` + +- Scenario ID:`MY-CONN-NET-001` + +### 5.6 MySQL 坏 SQL + +```bash +export DBTOOL_PASSWORD=dbtool +"$DBTOOL_BIN" query --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/mysql/failing_query.sql --result-format json +``` + +- Scenario ID:`MY-QUERY-SQL-001` + +## 6. 每条证据必须回填的字段 + +- Date (UTC) +- Operator +- Host +- Execution Path +- Binary Path +- Binary Version +- Commit SHA +- Branch +- Driver +- Scenario ID +- Command +- Exit Code +- Key Output +- Verdict + +推荐直接复制 `FAILURE_PATH_EVIDENCE_TEMPLATE.md`,首次执行可对照 `FAILURE_PATH_EVIDENCE_SAMPLE.md`。 + +如需减少人工抄录,可直接运行: + +```bash +scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failure-path-evidence +``` + +当前 helper 会: + +- 自动校验 `status` / `state` / `error.kind` +- 对可检测到的密码泄露模式直接判 `fail` +- 对 `unknown-*` 或空 traceability 字段直接判 `fail` +- 在 sidecar / host 两条路径下统一保留同一组 host / binary / version / commit / branch 标签 + +但回填前仍需人工复核 scenario、traceability 字段与输出摘要。 + +## 7. 回填顺序 + +1. 先把 6 条原始执行记录贴到 issue / 评论 +2. 再同步 `USABLE_EVIDENCE_LEDGER.md` +3. 再同步 `USABLE_RELEASE_GATE.md` +4. 若 6 条都满足预期,再把 failure-path blocker 从 `unverified` / `未满足` 移除 + +## 8. 异常处理规则 + +- 若 `status` 不是 `error`,先视为 contract 回归候选 +- 若 `state` 不是 `error`,先视为 shared app / CLI 映射回归候选 +- 若 `error.kind` 偏离矩阵预期,先拆 defect issue +- 若输出出现真实密码或敏感值,立即按安全缺陷处理 + +## 9. 清理 + +```bash +export DBTOOL_PASSWORD=dbtool +docker compose -f docker-compose.demo.yml down -v +``` + +## 10. 完成标准 + +- 6 条场景全部留档 +- 每条都有 traceability 字段 +- 每条都断言 `status` / `state` / `error.kind` +- `USABLE_EVIDENCE_LEDGER.md` 与 `USABLE_RELEASE_GATE.md` 已同步 diff --git a/PRE_RELEASE_CHECKLIST.md b/PRE_RELEASE_CHECKLIST.md index f857e47..ca95ad6 100644 --- a/PRE_RELEASE_CHECKLIST.md +++ b/PRE_RELEASE_CHECKLIST.md @@ -11,8 +11,8 @@ ## 功能门槛 -- [ ] PostgreSQL happy path 通过 -- [ ] MySQL happy path 通过 +- [x] PostgreSQL happy path 通过 +- [x] MySQL happy path 通过 - [x] SQLite happy path 通过 - [x] 至少一条连接失败路径已验证 - [x] 至少一条查询失败路径已验证 @@ -27,6 +27,6 @@ ## 当前状态 -- 当前清单已具备可执行代码、SQLite 主流程证据、SQLite non-db file failure-path 证据和 Linux release artifact smoke 证据,但仍不可整体判定通过。 -- 原因 1:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,也没有 `docker` 来完成 PostgreSQL / MySQL 的真实 smoke 复核。 -- 原因 2:当前 agent 环境没有 `cargo`,后续宿主机复核必须提前准备 Rust toolchain 或直接使用预编译二进制。 +- 当前清单已具备 SQLite 主流程证据、Linux release artifact smoke 证据、基于 [CMP-17](/CMP/issues/CMP-17#comment-0cd53e93-25a5-456b-8dba-31ea098c2aa8) 的宿主机 PostgreSQL / MySQL happy-path 证据、MySQL 修复分支的离线回归证据,以及 [CMP-23](/CMP/issues/CMP-23#comment-ab7a7c16-aaa3-4e62-9543-5055925201b8) 中完整的宿主机 Unicode patched-run 附件,但仍不可整体判定通过。 +- 原因 1:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,因此跨平台 release artifact 仍缺真实运行证据。 +- 原因 2:宿主机证据里二进制路径仍是占位符,commit SHA / branch 也未附上;这更像可追溯性补强项,而不是当前 MySQL Unicode 验收 blocker。 diff --git a/PRODUCT_REQUIREMENTS.md b/PRODUCT_REQUIREMENTS.md index 71fac9f..00aaa5f 100644 --- a/PRODUCT_REQUIREMENTS.md +++ b/PRODUCT_REQUIREMENTS.md @@ -6,9 +6,11 @@ This document defines the first release boundary for `dbtool-cli-v1` so the CTO ## Current Demo Audit -- As of 2026-03-25, the configured project workspace had no repository contents, no Rust workspace, no README, and no runnable CLI binary. -- The current project state is therefore a scoped backlog plus a project goal, not an already-working demo. -- This document should be treated as the product baseline for implementation, not as a polish pass on existing behavior. +- As of 2026-03-26, the repository contains a Rust workspace, a runnable CLI surface, demo fixtures, smoke runbooks, and future GUI foundation artifacts. +- The current demo already implements the shared operator flow for PostgreSQL, MySQL, and SQLite: `connect`, `inspect`, `query`, and `export`. +- Current user-visible behavior includes `.sql` file execution, parameterized query support, CSV / JSON export, and password injection via `--password-env` with redacted output. +- The remaining product work is not “invent the first scope from zero”; it is “freeze the first release boundary, clarify database support levels, and prevent advanced admin workflows from leaking into the current project.” +- `DATABASE_SUPPORT_MATRIX.md` is the controlling reference for database tiers, phase boundaries, and what “full support” means in product terms. ## Target Users @@ -37,6 +39,8 @@ The value is consistency across supported databases, not feature depth equal to Today, the operator would otherwise need to learn different connection rules, inspection commands, and export workflows across `psql`, MySQL tools, and SQLite tooling. `dbtool-cli-v1` should reduce that tool-switching cost for the most common technical workflows. +This product does not aim to replace native admin consoles or general-purpose SQL IDEs. Its first promise is a consistent core workflow, not full database-administration parity. + ## Primary V1 Workflow The most important end-to-end flow is: @@ -55,6 +59,7 @@ If this flow is not smooth, V1 is not successful. - local CLI only - database support for PostgreSQL, MySQL, and SQLite only +- Tier A core support for those three databases as defined in `DATABASE_SUPPORT_MATRIX.md` - explicit connection selection for each run - schema and table inspection - column-level metadata inspection @@ -66,6 +71,7 @@ If this flow is not smooth, V1 is not successful. ### Product Boundaries - V1 is an operator tool, not a visual database client. +- V1 “full support” means complete support for the core operator workflow, not complete coverage of every database-native administrative feature. - V1 must optimize for correctness and consistency before convenience features. - Exact command names, flag shapes, crate boundaries, and config storage are implementation choices owned by the CTO as long as they satisfy the acceptance criteria below. @@ -75,7 +81,13 @@ The following are explicitly out of scope for V1: - desktop GUI or web GUI - support for databases beyond PostgreSQL, MySQL, and SQLite +- saved profile management as a user-facing workflow - migration management or schema-editing workflows +- import workflows +- transaction management UX +- stored procedure or function management UX +- permission or user administration +- backup or restore workflows - ORM features, query builders, or notebook-style authoring - background sync, job scheduling, alerts, or long-running daemon behavior - shared team workspaces, cloud sync, or centralized connection management @@ -155,6 +167,16 @@ A QA engineer receives a local SQLite file: These scenarios should remain happy-path examples in engineering docs and QA checks. +## Database Scope Reference + +`DATABASE_SUPPORT_MATRIX.md` defines: + +- the product meaning of Tier A / B / C +- the current release commitment for PostgreSQL, MySQL, and SQLite +- which capabilities must stay uniform across databases +- which capabilities are allowed to differ by database +- which future databases may be evaluated after the current release validates + ## CLI To Product Evolution GUI is not in the current scope, but the CLI should evolve in a way that supports a later product surface. @@ -175,6 +197,8 @@ Future desktop or web surfaces should map directly to the same concepts: - results table - export action +The repository already includes `gui/desktop-foundation.md` and `gui/prototype/` as a future-facing design reference. Those artifacts should guide information architecture and workflow mapping only; they do not expand the current release surface beyond CLI. + ### Evolution Rule The future GUI should reuse the same underlying product workflow, not invent a separate one. The CLI is the first validation surface for product behavior. @@ -197,6 +221,7 @@ The following decisions are fixed for V1: - supported databases: PostgreSQL, MySQL, SQLite - surface: local CLI - critical path: connect, inspect, query, export +- support policy: all three current databases must meet Tier A for the core workflow, but advanced admin capabilities stay outside the project - GUI: out of scope - acceptance must cover both success and obvious failure paths @@ -213,6 +238,7 @@ The following decisions remain implementation-owned by the CTO: `dbtool-cli-v1` is ready for a first release when: - the primary workflow works across PostgreSQL, MySQL, and SQLite +- the Tier A expectations in `DATABASE_SUPPORT_MATRIX.md` are met without scope creep into Tier C areas - the documented non-goals have not crept into scope - QA can execute acceptance checks directly from this document and companion test material - CTO can map the remaining work into engineering tasks without reopening basic product questions diff --git a/QA_RUNTIME_ENVIRONMENT.md b/QA_RUNTIME_ENVIRONMENT.md index 92f5910..77b467e 100644 --- a/QA_RUNTIME_ENVIRONMENT.md +++ b/QA_RUNTIME_ENVIRONMENT.md @@ -2,13 +2,16 @@ ## 结论 -当前阶段的推荐方案是:**让 QA 在宿主机执行 smoke,但数据库仍使用仓库内的 Docker Compose 临时拉起**。 +当前阶段的目标推荐方案是:**让 QA agent 直接在升级后的 `local-db-codex` 容器内执行 smoke,并通过挂载宿主机 Docker daemon、Docker 配置、Cargo 与 Rustup 目录来复用宿主能力**。 - 继续复用现有 `docker-compose.demo.yml`、bootstrap 脚本和 SQL fixtures - 不依赖长期运行的宿主机 PostgreSQL / MySQL -- 不在当前阶段扩展 `local-db-codex` 运行时去支持 Docker +- 不再要求 QA 手动切回宿主机执行 +- 将 Docker、Rust、Cargo mirror、Docker registry mirror 的能力统一沉淀到容器运行时 -这条路径最符合当前项目状态:仓库里的 smoke 输入已经存在,真正缺的是一个有 Docker 的执行宿主,而不是新的产品代码或平台改造。 +在该部署升级完成前,宿主机执行仍然是可用 fallback;但后续默认路径应转向容器内 agent 自行完成 smoke。 + +2026-03-26 的 [CMP-17](/CMP/issues/CMP-17#comment-0cd53e93-25a5-456b-8dba-31ea098c2aa8) 已证明该路径可以成立:宿主机 PostgreSQL / MySQL smoke 已执行完成,因此“当前 QA runner 无 Docker”只代表本 agent 本地限制,不再代表方案无效。 ## 三种方案对比 @@ -57,62 +60,119 @@ - 需要一台具备 Docker / `docker compose` 的宿主机 - 宿主机上仍需准备 Rust toolchain 或 release binary -结论:**这是当前推荐方案。** +结论:**作为 fallback 可用,但不再是长期目标路径。** + +### 方案 D:升级 `local-db-codex`,让 agent 在容器内复用宿主机 Docker 和 Rust + +优点: + +- agent 可直接在容器内执行 PostgreSQL / MySQL Docker-backed smoke +- 后续每个功能实现后的测试路径一致,不需要在人和 agent 之间切换执行环境 +- 继续复用宿主机 Docker daemon 的镜像缓存、registry mirror 和权限体系 +- 继续复用宿主机 Rust toolchain、Cargo 配置和 crates mirror 配置 + +缺点: + +- 需要维护容器与宿主机之间的挂载关系 +- Docker socket 暴露会提升容器能力边界,需明确接受该风险 +- 宿主机 Docker / Rust 环境本身出问题时,容器内 agent 会继承同样问题 + +结论:**这是后续迭代的目标方案。** ## 推荐执行方式 -### 宿主机要求 +### 容器化 QA runner 要求 -- Linux 或 macOS 宿主机 -- 已安装 Docker 和 `docker compose` -- 可运行 Rust `cargo`,或已取得本仓库 release / debug 二进制 -- 2026-03-26 当前 QA agent runner 不满足该要求:它有 `node v24.14.0` 和预编译 `target/release/dbtool`,但缺少 `docker` 与 `cargo` +- `deploy/local-db-codex` 已升级,挂载: + - 宿主机 Docker socket + - 宿主机 Docker config + - 宿主机 Cargo home + - 宿主机 Rustup home +- 容器内可直接运行: + - `docker` + - `cargo` + - `rustc` +- 宿主机 Docker daemon 已配置好 registry mirror(如有) +- 宿主机 Cargo / Rustup mirror 配置已就绪(如有) ### 使用步骤 -1. 在宿主机拉取当前仓库代码并进入项目根目录 -2. 准备 CLI: +1. 启动升级后的 `local-db-codex` 部署 +2. 进入 agent 所在容器运行环境,确认 `docker`、`cargo`、`rustc` 可用 +3. 在工作区准备 CLI: - 开发验证:`cargo build` - 或使用已生成的 `target/debug/dbtool` / `target/release/dbtool` -3. 导出密码环境变量: +4. 导出密码环境变量: ```bash export DBTOOL_PASSWORD=dbtool ``` -4. 拉起 PostgreSQL / MySQL: +5. 拉起 PostgreSQL / MySQL: ```bash docker compose -f docker-compose.demo.yml up -d postgres mysql ``` -5. 导入 demo 数据: +6. 导入 demo 数据: ```bash ./examples/scripts/bootstrap-postgres.sh ./examples/scripts/bootstrap-mysql.sh ``` -6. 按 `SMOKE_RUNBOOK.md` 执行 PostgreSQL / MySQL 的 `connect` / `inspect` / `query` / `export` -7. 将结果回填到 `ACCEPTANCE_CHECKLIST.md`、`TEST_STRATEGY.md`、`PRE_RELEASE_CHECKLIST.md` -8. 清理环境: +7. 按 `SMOKE_RUNBOOK.md` 执行 PostgreSQL / MySQL 的 happy-path;按 `HOST_FAILURE_PATH_CHECKLIST.md` 执行 failure-path +8. 将结果回填到 `ACCEPTANCE_CHECKLIST.md`、`TEST_STRATEGY.md`、`PRE_RELEASE_CHECKLIST.md` +9. 清理环境: ```bash docker compose -f docker-compose.demo.yml down -v ``` +## Failure-path 执行入口 + +- PostgreSQL / MySQL happy-path 继续使用 `SMOKE_RUNBOOK.md` +- PostgreSQL / MySQL failure-path 统一使用 `HOST_FAILURE_PATH_CHECKLIST.md` +- 证据记录格式继续以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准 + +## 2026-03-28 当前容器化 runner 观察 + +- QA runner 已可访问 Docker daemon,但当前仍缺 `docker compose` 子命令。 +- QA runner 不能直接访问 `127.0.0.1:55432/53306`,也不能直接访问 demo 数据库容器 IP。 +- 当前有效补充路径是: + 1. 复用已运行的 `dbtool-cli-v1-postgres-1` / `dbtool-cli-v1-mysql-1` + 2. 用 `docker exec` 直接导入 PostgreSQL / MySQL demo 数据 + 3. 创建 sidecar container 并加入 `dbtool-cli-v1_default` 网络 +4. 在 sidecar container 内执行 `dbtool`,主机名使用 `postgres` / `mysql` + +这条 sidecar network execution 路径在 2026-03-28 当前 heartbeat 已被直接验证,可用于继续补 PostgreSQL / MySQL failure-path 证据。 + +为减少重复手工步骤,当前仓库已提供: + +```bash +scripts/qa/run-failure-path-evidence-sidecar.sh ./target/release/dbtool ./tmp/failure-path-evidence +``` + +这条脚本化路径会自动完成: + +- PostgreSQL / MySQL demo 数据重导入 +- sidecar container 创建与清理 +- sidecar 内 failure-path helper 执行 +- 证据文件回传到本地输出目录 + ## 团队职责 -- 后端:维护 `docker-compose.demo.yml`、bootstrap 脚本、fixtures 和命令契约 -- QA:在宿主机执行 smoke,沉淀证据和失败复现记录 +- 后端:维护 `docker-compose.demo.yml`、bootstrap 脚本、fixtures、命令契约和容器内 smoke 兼容性 +- QA:优先在容器内 agent 运行环境执行 smoke,沉淀证据和失败复现记录 - 前端:当前继续 gated,不参与此轮 runtime smoke -- CTO:维持方案边界;若没有 Docker-capable host,再升级为平台支持请求 +- CTO:维持方案边界,并确保 `local-db-codex` 能长期承载容器内 smoke ## 风险与维护成本 -- 宿主机不可用:需要指定一台固定 QA 主机或自托管 runner -- 二进制与文档漂移:QA 执行时必须绑定具体 commit 或 release artifact -- 端口冲突:优先使用专用 QA 主机;必要时再参数化 compose 端口 -- 当前 runner 与目标宿主能力不一致:本地可继续复核 SQLite 与预编译二进制,但 PostgreSQL / MySQL Docker smoke 必须迁移到符合要求的宿主机 +- Docker socket 暴露:容器获得了较强的宿主机控制能力,必须明确接受这一安全边界。 +- 宿主机 Docker / Rust 环境漂移:容器会继承宿主机配置,出现问题时必须同时排查宿主机。 +- 二进制与文档漂移:QA 执行时必须绑定具体 commit 或 release artifact。 +- 端口冲突:必要时继续参数化 compose 端口。 +- 当前已知结论:宿主机 patched-run 已确认 MySQL 中文与 emoji 输出正常,问题根因收敛到 bootstrap/import 字符集路径;后续需要把这类验证稳定迁入容器内 smoke 路径。 -总体维护成本低,因为主路径复用仓库现有资产,不引入新的长期数据库运维负担,也不引入新的平台能力改造。 +总体维护成本可控,因为主路径继续复用仓库现有 smoke 资产,只是把执行宿主从人工宿主机切换成可复用宿主能力的 Paperclip 容器。 diff --git a/README.md b/README.md index aaf5e22..c45a98d 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,17 @@ - 面板焦点管理与基础键盘导航 - `sqlite-local` 的真实 connect / inspect 路径 - `sqlite-local` 的真实 query / results / export 工作流 +- PostgreSQL / MySQL 的 Docker demo live activation / query / export 路径 - `Ready` / `Loading` / `Error` 基础状态框架 +当前这组能力在 `dbtool-usable-v1` 口径下需要分层理解: + +- TUI / shell baseline:已有直接执行证据 +- TUI / `sqlite-local` local live:只能记为 `partial` +- TUI / PostgreSQL & MySQL network live:已具备 runner 内可重复 demo path + +因此,`sqlite-local` 继续承担本地单机验证,而 PostgreSQL / MySQL 已可通过 `host.docker.internal` 与 `scripts/tui/live-network-smoke.sh` 完成 runner 内真实 network live 复验。 + 运行方式: ```bash @@ -63,7 +72,36 @@ cargo run -p dbtool-tui ./target/debug/dbtool-tui ``` -详细范围与验收说明见 `apps/tui/README.md`,共享 UI 方案见 `plans/2026-03-26-dbtool-tui-shell-ui-scope.md`。 +最小可重复 smoke 入口: + +```bash +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui +``` + +`dbtool-tui` 需要真实 TTY;`--help` 不是有效 smoke 入口。详细范围与验收说明见 `apps/tui/README.md`、`TUI_SMOKE_RUNBOOK.md`,共享 UI 方案见 `plans/2026-03-26-dbtool-tui-shell-ui-scope.md`。 + +## GUI Foundation Preview + +仓库当前没有正式 Web / Desktop 前端工程,但已经提供最小可见展示层,用于评审未来 GUI 的信息架构与工作台交互: + +- `gui/index.html`:GUI foundation 入口页 +- `gui/prototype/index.html`:静态数据库工作台原型 +- `gui/preview-server.mjs`:本地预览服务 +- `gui/README.md`:运行方式、范围说明与界面验收要点 + +运行方式: + +```bash +node gui/preview-server.mjs +``` + +然后访问: + +```text +http://127.0.0.1:4173 +``` + +如果只想快速查看静态界面,也可以直接打开 `gui/index.html`。 ## Workspace 结构 @@ -99,18 +137,42 @@ cargo run -p dbtool-cli -- query --driver sqlite --path examples/tmp/dbtool-demo cargo run -p dbtool-cli -- export --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/export_query.sql --format csv --output examples/tmp/export.csv ``` -如果当前 Linux 环境没有系统 `cc`,可以改用一个 C 兼容 linker,例如 `zig cc`: +如果当前 Linux 环境没有系统 `cc` / `ar`,可以改用 `zig cc` + `zig ar`。对 bundled SQLite 路径,linker 包装器需要兼容 Cargo 传入的 GNU target flag: ```bash cat > /tmp/zig-cc <<'EOF' -#!/bin/sh -exec zig cc "$@" +#!/usr/bin/env bash +set -euo pipefail +args=() +for arg in "$@"; do + case "$arg" in + --target=x86_64-unknown-linux-gnu) + args+=("--target=x86_64-linux-gnu") + ;; + *) + args+=("$arg") + ;; + esac +done +exec zig cc "${args[@]}" +EOF +cat > /tmp/zig-ar <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec zig ar "$@" EOF chmod +x /tmp/zig-cc +chmod +x /tmp/zig-ar export CC=/tmp/zig-cc +export AR=/tmp/zig-ar export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/tmp/zig-cc -cargo test -cargo run -p dbtool-cli -- --help + +# 如果当前环境的 rustup 没有默认 toolchain,可显式指定 stable 工具链 +export RUSTC="$(rustup which --toolchain stable rustc)" +export RUSTDOC="$(rustup which --toolchain stable rustdoc)" +"$(dirname "$RUSTC")/cargo" test --workspace +"$(dirname "$RUSTC")/cargo" build --release --bin dbtool +./target/release/dbtool --help ``` 最小发布与产物 smoke 已定义在 `.github/workflows/release-smoke.yml` 与 `RELEASE_RUNBOOK.md`;当前 Linux 还可本地执行 `scripts/release/smoke-binary.sh` 和 `scripts/release/package-unix.sh` 复核产物命名与 checksum。 diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index a442c9b..3222b02 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -10,10 +10,14 @@ - 当前已观察到 Rust workspace、CLI 二进制、release artifact、GitHub release-smoke workflow、demo seed SQL、query fixture、bootstrap 脚本和 smoke runbook。 - 当前 agent 环境可直接执行本地 Linux + SQLite 的运行型验证。 - 当前 agent 环境有 `node v24.14.0` 与预编译 `target/release/dbtool`,因此 SQLite bootstrap 与本地二进制 smoke 可重复执行。 -- 当前 agent 环境仍缺 `docker`,因此 PostgreSQL / MySQL 运行时 smoke 不能在此本地复现。 -- 当前 agent 环境也缺 `cargo`,因此 Docker-capable host 若无 Rust toolchain,需改用已构建二进制或 release artifact 执行 smoke。 +- 2026-03-26 [CMP-17](/CMP/issues/CMP-17#comment-0cd53e93-25a5-456b-8dba-31ea098c2aa8) 已回填:PostgreSQL / MySQL runtime smoke 在具备 Docker 的宿主机执行完成,因此“当前 QA runner 无 Docker”不再是该路径的发布 blocker。 +- 当前 agent 环境中的 Rust toolchain不在默认 PATH,但可通过显式 PATH 与 `zig` 包装器运行 `cargo test` / `cargo build`。 - 当前 agent 环境也无法替代 GitHub 的 macOS / Windows runner 证据。 - 2026-03-26 已补做 SQLite non-db file 回归:`connect` 现在会在前置检查阶段失败,不再误报成功。 +- 2026-03-26 已补做 MySQL 修复离线回归:驱动侧 `mysql_` 定向测试通过、CLI 测试通过、release binary 重建成功。 +- 2026-03-27 新宿主机线索与 [CMP-22](/CMP/issues/CMP-22#comment-9b4ec6e1-408d-483d-a319-818be09d6d79) 一致:在干净重建并显式 UTF-8 导入后,MySQL 官方 CLI、`dbtool query`、`dbtool export` 都报告正常。 +- 2026-03-27 [CMP-23](/CMP/issues/CMP-23#comment-ab7a7c16-aaa3-4e62-9543-5055925201b8) 已补齐宿主机 Unicode patched-run 证据:字符集变量、collation 变量、官方 CLI 查询、`dbtool query`、`dbtool export` 摘要均可审计。 +- 2026-03-26 当前 live retest 仍缺环境:本机 `127.0.0.1:53306` 与 `127.0.0.1:55432` 端口关闭,无法直接完成修复后的 MySQL / PostgreSQL runtime 复验。 ## 测试优先级 @@ -62,13 +66,13 @@ ## 当前明显缺口 -- 缺 PostgreSQL / MySQL 的本地 Docker 执行证据 - 缺 GitHub macOS / Windows release runner 的真实执行证据 +- 缺宿主机验证使用的具体 binary path 与 commit SHA / branch 留档 - 缺大结果集 / 超时 / 空结果导出等剩余边界证据 ## 下一次验证入口 -- 在 Docker 可用环境执行 PostgreSQL / MySQL `connect` / `inspect` / `query` / `export` smoke;若宿主无 `cargo`,直接使用 `target/release/dbtool` 或 release artifact。 +- 为已完成的宿主机 MySQL Unicode patched run 补上具体 binary path 与 commit SHA / branch,增强验收可追溯性。 - 继续补齐 SQLite missing file 与其余边界路径证据。 - 在 CI 或等价 runner 上收集 Linux / macOS / Windows release artifact 证据。 - 将新增结果持续回填到验收检查表和发布前清单。 diff --git a/TUI_ACCEPTANCE_CHECKLIST.md b/TUI_ACCEPTANCE_CHECKLIST.md index 8d103bf..af2986c 100644 --- a/TUI_ACCEPTANCE_CHECKLIST.md +++ b/TUI_ACCEPTANCE_CHECKLIST.md @@ -2,79 +2,111 @@ ## 当前结论 -- 当前仍不能把 `dbtool-tui-v1` 整体判定为阶段 2 live integration 已全部通过;但 `CMP-36` 的 `sqlite-local` 真实 query / results / export 路径已经完成宿主机侧重建与验收回填。 +- 当前仍不能把 `dbtool-tui-v1` 的全部发布前工作都判定为完成;但当前 runner 已建立 PostgreSQL / MySQL 的 TTY live demo path,`CMP-55` 范围内的 network live blocker 已解除。 - Product / PM 对齐依据:`[CMP-33](/CMP/issues/CMP-33)`、`[CMP-37](/CMP/issues/CMP-37)`、`plans/2026-03-27-dbtool-tui-v1-phase-2-scope.md`。 -- CTO 优先级对齐依据:第二阶段关键路径仍是 `[CMP-34](/CMP/issues/CMP-34)` → `[CMP-35](/CMP/issues/CMP-35)` → `[CMP-36](/CMP/issues/CMP-36)`,QA 不应越过 shared app 契约提前宣告 live 通过。 +- CTO 优先级对齐依据:当前应优先补 live 覆盖深度(failure / empty / restricted / browse-stability)与 release smoke,而不是继续把 “缺少 live-path” 当成主 blocker。 - 2026-03-27 当前 runner 已回填 shell baseline 证据:`./target/debug/dbtool-tui` 在 `120x40` 与 `90x20` TTY 路径都可复现,退出码均为 `0`。 -- 当前 runner 不能把“源码可重建”记为通过:系统无 `cargo` / `rustc` / `rustfmt`,无法执行 `cargo check`、`cargo test -p dbtool-tui` 或重新生成二进制。 -- `dbtool-tui` 是 TTY 程序;`./target/debug/dbtool-tui --help` 在无 TTY 场景下返回 `No such device or address`,这必须被记录为非交互限制,而不是被误记为 smoke 失败或帮助能力已通过。 +- 2026-03-28 当前 runner 已直接执行 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` 与 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo test -p dbtool-tui`;默认裸 `cargo` 仍会因为空的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 环境变量而失败,这属于环境口径问题,不是产品失败。 +- `dbtool-tui` 是 TTY 程序;2026-03-28 源码重建后的 `./target/debug/dbtool-tui --help` 已返回退出码 `1`,并明确提示需要交互式 TTY 和 `scripts/tui/smoke-tty.sh`,不能再把旧的 `No such device or address` 产物输出当成当前契约。 +- 2026-03-31 当前 runner 已确认可通过 `host.docker.internal:55432/53306` 访问 demo 数据库,并直接通过 `scripts/tui/live-network-smoke.sh` 复跑 PostgreSQL / MySQL TUI live path。 + +## usable-v1 签收关注点 + +### 1. 状态恢复 + +- `r` 必须被视为当前唯一显式“恢复到 Ready”的键位;它会同时清空 query / export 反馈,而不是只重置单一面板。 +- 失败连接、坏 SQL、空结果、导出错误都必须在工作区持续可见,直到用户主动切换连接、切换草稿或按 `r` 清空。 +- 查询运行中若立即导出,界面应给出明确提示 `Wait for the current query to finish before exporting.`,而不是静默无响应。 + +### 2. 键位一致性 + +- `Enter` 只跟随当前焦点语义变化,不应在同一焦点下承担多个冲突动作。 +- `Esc` 在编辑模式与普通导航模式都应把用户带回更稳定、更可理解的状态。 +- `[` / `]` 只在 `Query Editor` 与 `Results` 生效;文档和 QA 结论不能再写入不存在的快捷键。 ## 验收分层 ### A. Shell baseline(当前可验) - [x] 预构建二进制 `target/debug/dbtool-tui` 存在 -- [x] `printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` 可稳定启动并退出 +- [x] `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` 可稳定启动并退出 - [x] 正常尺寸下可见六区布局:`Connections`、`Schema Browser`、`Query Editor`、`Results`、`Inspector`、`Status & Activity` - [x] 正常尺寸下默认状态包含 `Ready`、`Execution Idle`、`Workspace` -- [x] 当前 shell 会展示静态连接 / schema / query / results 上下文,但这些仍是本地示例状态,不是 live shared app 数据 -- [x] `printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` 可稳定启动并退出 +- [x] 当前 shell baseline 的可见工作区可稳定复现,但仅凭这组可见文本不能单独记成 live shared app 验收通过 +- [x] `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/tui-small.log` 可稳定启动并退出 - [x] 小终端路径会显示 `Terminal Too Small` - [x] 小终端路径会显示 `Resize the terminal` - [x] shell baseline 与 live integration 的验收边界已在本文档中显式分层 -- [ ] 当前 runner 上源码重建通过 -- [ ] 当前 runner 上源码测试通过 +- [x] 当前 runner 上源码重建通过:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` +- [x] 当前 runner 上源码测试通过:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo test -p dbtool-tui` - [ ] `dbtool-tui` 独立 release artifact smoke 已建立 -### B. 第二阶段 live integration(当前必须保持未通过) +### B. 第二阶段 live integration(当前 runner demo path 已可复验) -- [ ] `[CMP-34](/CMP/issues/CMP-34)` 已冻结 TUI live integration 契约 -- [ ] `[CMP-35](/CMP/issues/CMP-35)` 的 `sqlite-local` 真实 connect / inspect 路径已完成源码重建并回填运行证据 -- [x] `[CMP-36](/CMP/issues/CMP-36)` 的 `sqlite-local` 真实 query / results / export 路径已完成源码重建并回填运行证据 +- [x] 当前 runner 已有可重复的 PostgreSQL / MySQL TTY live smoke 入口 +- [x] 当前 runner 已直接观察到真实连接激活与 schema 加载 happy path +- [x] `sqlite-local` 与 network live 都复用 `crates/db-app`,不通过 CLI 文本解析旁路取数 +- [x] PostgreSQL live smoke:`scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` +- [x] MySQL live smoke:`scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` - [ ] 真实连接激活可区分 `loading` / `success` / `failure` - [ ] 真实连接失败后,错误能在 `Inspector` 与 `Status & Activity` 中持续可见 - [ ] 真实 schema browser 可展示 schema / object / column 层级 - [ ] empty schema 与 restricted schema 在 live 数据下仍有明确文案 -- [ ] `Query Editor` 可针对真实活动连接执行查询 +- [x] `Query Editor` 可针对真实活动连接执行查询 - [x] 真实查询可区分 `running` / `success` / `empty` / `error` - [ ] 真实结果区在常见终端尺寸下仍可浏览 - [x] export 结果会给出明确成功 / 失败反馈 -- [ ] 全路径复用 `crates/db-app`,不通过 CLI 文本解析旁路取数 + +### D. usable-v1 状态恢复与键盘一致性(当前可按现有实现复核) + +- [x] `r` 会同时清空 query 结果、query 错误与 export feedback,并把界面恢复到 `Ready` +- [x] `Esc` 在普通导航下会回到默认 `Workspace` 与 `Query Editor` 焦点 +- [x] `Esc` 在编辑模式下只退出 insert mode,不会误触发查询执行 +- [x] 插入模式下 `Enter` 只插入换行,并给出 `Press Esc, then Enter to run` 提示 +- [x] 查询运行中按 `x` / `j` 会给出等待提示,而不是覆盖当前运行状态 +- [x] `[` / `]` 在 `Query Editor` 与 `Results` 之外不会制造隐藏状态变化 +- [x] 当前对外文档已与实现一致,不再声明不存在的 `l` / `e` 快捷键 ### C. 非交互 / 发布前路径(当前必须显式保留限制) - [x] QA 已确认 `dbtool-tui` 需要 TTY,不能用 `--help` 替代交互 smoke - [ ] GitHub Actions 已覆盖 `dbtool-tui` smoke - [ ] 非开发者可按 runbook 从 packaged binary 复现 TUI 验收 -- [ ] TUI 专属 demo / seed / Docker 路径已建立 +- [x] TUI 专属 demo / seed / Docker 路径已建立 - [ ] release artifact / packaged binary 在目标平台有真实执行证据 ## 当前 blocker -- [ ] `[CMP-34](/CMP/issues/CMP-34)` 仍在 `in_progress` -- [ ] `[CMP-35](/CMP/issues/CMP-35)` 仍在 `in_progress`,且当前 runner 缺少 Rust 工具链,无法回填重建证据 -- [x] `[CMP-36](/CMP/issues/CMP-36)` 已完成宿主机侧构建与验收回填;当前 agent runner 仍缺 Rust 工具链,但不再阻塞该 issue 关闭 +- [ ] live failure 连接在 `Inspector` / `Status & Activity` 的持续可见性仍缺 2026-03-31 当前 runner 证据 +- [ ] live `empty` / `restricted` schema 文案仍缺 2026-03-31 当前 runner 证据 +- [ ] 结果区在常见终端尺寸下的稳定浏览仍缺 2026-03-31 当前 runner 证据 - [ ] `.github/workflows/release-smoke.yml` 当前只覆盖 `dbtool` -- [ ] 仓库仍无 TUI 专属 demo / seed / Docker live 验证路径 -- [ ] 当前 runner 缺 `cargo` / `rustc` / `rustfmt`,无法执行源码重建、测试与格式化 +- [ ] 当前 runner 默认裸 `cargo` 仍会被空的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 环境变量绊倒,runbook 需要显式写出 override +- [ ] release artifact / packaged binary 仍缺真实 smoke 证据 -## 2026-03-27 已回填证据 +## 2026-03-27 ~ 2026-03-31 已回填证据 -- 正常尺寸 shell smoke:`printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` +- 正常尺寸 shell smoke:`scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` - 正常尺寸 shell 结果:退出码 `0`;日志包含六区布局、`Ready`、`Execution Idle`、静态 connections/schema/results 文案 -- 小终端 shell smoke:`printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` +- 小终端 shell smoke:`scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/tui-small.log` - 小终端 shell 结果:退出码 `0`;日志包含 `Terminal Too Small` 与 `Resize the terminal` -- 非交互限制:`./target/debug/dbtool-tui --help` 返回 `Error: Os { code: 6, kind: Uncategorized, message: "No such device or address" }` -- 源码状态盘点:`apps/tui/src/main.rs` 已引入 `sqlite-local` 的真实 connect / inspect / query / results / export worker 接线,以及内联测试覆盖 -- 源码验证限制:当前 runner 无 `cargo` / `rustc`,因此无法在本次回合执行 `cargo check`、`cargo test -p dbtool-tui` 或重新生成 `target/debug/dbtool-tui` +- 源码重建:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` +- 源码测试:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo test -p dbtool-tui`,当前直接通过 `9` 个内联测试 +- 非交互限制(源码重建后二进制):`./target/debug/dbtool-tui --help` 返回退出码 `1`,并输出 `dbtool-tui requires an interactive TTY...` 与 `Use \`scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui\`...` +- 源码状态盘点:`apps/tui/src/main.rs` 已引入 `sqlite-local` 与 PostgreSQL / MySQL demo profile 的真实 connect / inspect / query / results / export worker 接线,以及内联测试覆盖 +- Docker / demo 盘点:`docker --version` 与 `docker ps` 通过;虽然 `docker compose version` 失败,但当前 demo 容器健康,且 `scripts/tui/live-network-smoke.sh` 已可在当前 runner 直接复跑 PostgreSQL / MySQL happy path +- PostgreSQL live smoke:`scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` 直接通过;导出产物 `/tmp/dbtool-tui-reporting-postgres-account-ticket-summary-sql.csv` +- MySQL live smoke:`scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` 直接通过;导出产物 `/tmp/dbtool-tui-orders-mysql-account-ticket-summary-sql.csv` +- PostgreSQL live 日志摘要:已直接观察到 `Validation: Connected`、`3 rows returned`、`3 rows exported` +- MySQL live 日志摘要:已直接观察到 `Validation: Connected`、`3 rows returned`、`3 rows exported` +- network path 盘点:当前 runner 对 `127.0.0.1:55432/53306` 仍不可达,但可通过 `host.docker.internal:55432/53306` 稳定访问 demo 容器 ## 当前不应误判为已通过的能力 -- [ ] 真实 shared app 连接切换 +- [x] 真实 shared app 连接切换 - [ ] 真实 inspect / schema browser -- [ ] 真实 query 执行 +- [x] 真实 query 执行 - [ ] 真实 empty / error 结果处理 -- [ ] 真实 export 反馈 +- [x] 真实 export 反馈 - [ ] TUI CI smoke - [ ] TUI release artifact smoke -- [ ] TUI demo / seed / Docker 路径 diff --git a/TUI_BACKEND_CONTRACT.md b/TUI_BACKEND_CONTRACT.md index 94c29eb..e81f0b8 100644 --- a/TUI_BACKEND_CONTRACT.md +++ b/TUI_BACKEND_CONTRACT.md @@ -195,16 +195,25 @@ JSON 返回 envelope: { "status": "success", "operation": "query", + "state": "success", "data": {} } ``` +- `status` 继续只区分 envelope 级成功/失败 +- `state` 对齐共享应用层状态: + - `connect`:`success` + - `inspect`:`success | empty` + - `query`:`success | empty` + - `export`:`success` + 错误返回 envelope: ```json { "status": "error", "operation": "query", + "state": "error", "error": { "operation": "query", "kind": "query", diff --git a/TUI_PRODUCT_REQUIREMENTS.md b/TUI_PRODUCT_REQUIREMENTS.md new file mode 100644 index 0000000..8d74d4b --- /dev/null +++ b/TUI_PRODUCT_REQUIREMENTS.md @@ -0,0 +1,284 @@ +# dbtool-tui-v1 Product Requirements + +日期:2026-03-26 +作者:Product Manager +对应 issue:`CMP-24` + +## 1. 文档目的 + +这份文档定义 `dbtool-tui-v1` 的首版产品范围、用户流程、最小功能清单和验收标准,用于: + +- 让 CTO 可以继续拆工程 issue +- 让 Frontend / Backend 对 TUI 边界保持一致 +- 让 QA 能建立独立于 CLI 的验收路径 + +## 2. 当前真实状态 + +截至 2026-03-26,仓库已经具备: + +- `apps/tui` 终端工作台 shell baseline +- `crates/db-app` 作为 CLI / TUI 共享应用编排层 +- `TUI_BACKEND_CONTRACT.md`、`TUI_ACCEPTANCE_CHECKLIST.md`、`TUI_TEST_STRATEGY.md` 等配套文档 + +当前 shell baseline 已验证: + +- 六区布局 +- 键盘焦点切换 +- 顶部视图切换 +- `Ready` / `Loading` / `Error` 基础状态 +- 小终端降级提示 + +当前 shell baseline **尚不等于 TUI V1 完成**。它只是终端工作台壳层,不包含真实数据库工作流闭环。 + +## 3. 目标用户 + +### 主用户 + +已经熟悉命令行、但希望在一个终端会话里连续完成 inspect / query / result 浏览的技术操作者: + +- 后端工程师 +- 数据工程师 +- QA 工程师 +- 技术支持 / 排障工程师 + +### 与 CLI 用户的关系 + +CLI 更适合: + +- 一次性命令执行 +- shell automation +- CI / smoke / 脚本调用 + +TUI 更适合: + +- 在同一终端会话中反复查看 schema、改 SQL、跑查询、读结果 +- 降低重复输入命令的成本 +- 提高排障和验证过程中的上下文连续性 + +## 4. 产品问题定义 + +CLI 已经验证了 `connect -> inspect -> query -> export` 的产品语义,但在高频排障场景下,操作者仍要不断重复: + +- 重输命令 +- 来回切换 schema / table 上下文 +- 重新打开 SQL 文件或复制 SQL +- 在输出滚动中回看错误和结果 + +`dbtool-tui-v1` 要解决的问题不是“替代桌面 GUI”,而是把已成立的 CLI 核心流程,升级成一个**键盘优先、上下文连续、仍然运行在终端里的单会话工作台**。 + +## 5. 最重要的用户场景 + +### 场景 A:排障查询 + +后端或支持工程师需要在一个终端会话内快速定位异常数据: + +1. 选择一个已有连接目标 +2. 验证并激活当前连接 +3. 浏览 schema / table / column +4. 把目标对象上下文带入 query editor +5. 运行 SQL +6. 在结果区和 inspector 中查看结果、空结果或错误 +7. 需要时导出结果 + +### 场景 B:测试验证 + +QA 工程师需要在终端里连续验证多个数据点: + +1. 切换到目标连接 +2. 通过 schema browser 定位对象 +3. 多次调整 SQL +4. 查看结果是否符合预期 +5. 导出一份结果作为证据 + +如果这些场景仍然要求用户频繁退回 CLI 重新拼命令,TUI V1 就没有成立。 + +## 6. 核心用户流程 + +`dbtool-tui-v1` 的首版主流程应是: + +`select target -> activate connection -> inspect objects -> edit query -> run query -> review result/error -> export` + +拆成界面动作: + +1. 在 `Connections` 中看到当前可切换目标 +2. 选择并激活一个连接目标 +3. 在 `Schema Browser` 中逐层浏览 schema / table / column +4. 在 `Query Editor` 中编写或修改 SQL +5. 触发执行 +6. 在 `Results` 中读取结果,在 `Inspector` / `Status & Activity` 中读取状态与错误 +7. 对当前结果集触发导出 + +## 7. 首版范围 + +### In Scope + +- 终端中的单会话数据库工作台 +- 单一活动连接上下文 +- 键盘驱动的连接选择与激活 +- live schema / table / column 浏览 +- 最小可用 query editor +- live query 执行 +- 结果表格浏览 +- 空结果、执行中、执行失败、执行成功的明确反馈 +- 对当前结果集发起 export +- 基于 `db-app` 的共享结果对象和错误对象 + +### 最小功能范围清单 + +- `Connections` + - 展示当前会话内可切换目标 + - 明确当前激活目标 + - 明确失败目标或不可用目标 + +- `Schema Browser` + - 浏览 schema / table / column + - 反映当前激活连接上下文 + +- `Query Editor` + - 至少支持查看、输入、编辑和再次执行 SQL + - 不要求完整 IDE 能力 + +- `Results` + - 展示列头、结果行、空结果状态 + - 对较长结果保持基本可读和可滚动 + +- `Inspector / Status & Activity` + - 展示连接摘要 + - 展示执行状态 + - 展示错误摘要和恢复提示 + +- `Export` + - 对当前结果集触发导出 + - 明确格式、路径、成功或失败反馈 + +## 8. 非目标 + +以下内容不进入 `dbtool-tui-v1`: + +- Desktop GUI / Web GUI +- 多连接并发在线工作区 +- 用户可见的持久化 profile 管理 +- 新建复杂连接配置中心 +- 多 query tab +- query history 持久化 +- SQL autocomplete / formatter / explain plan +- migration / schema editing +- import +- 权限 / 用户管理 +- backup / restore +- dashboard、图表、BI 分析面板 +- AI 助手 + +## 9. CLI、TUI、后续版本的边界 + +### 应保留在共享 `db-app` / core 的 + +- 连接测试 +- inspect 结构 +- query 请求与结果 +- export 请求与结果 +- 错误分类 +- 脱敏规则 + +### 应保留在 CLI 的 + +- 一次性脚本执行 +- shell automation +- 原始 flags / file path 输入 +- 机器可组合的命令式入口 + +### 应进入 TUI V1 的 + +- 单会话连接切换 +- 面板化 inspect / query / result 浏览 +- 错误与状态的持续可见反馈 +- 对当前结果的交互式导出动作 + +### 留到后续版本的 + +- 持久化连接管理 +- 多标签页工作流 +- 高级 SQL 编辑器能力 +- 更复杂的历史、搜索和协作能力 + +## 10. 验收标准 + +### 全局验收 + +- TUI V1 必须仍然复用 CLI 已验证的产品语义,而不是重造一套数据库行为 +- TUI V1 必须是键盘优先的完整终端路径,不依赖鼠标 +- TUI V1 必须有独立于 CLI 的验收和回归路径 +- TUI V1 不能被误写成桌面 GUI 或完整数据库管理平台 + +### 连接工作流验收 + +当以下条件成立时,连接流程可接受: + +- 用户可以看到当前会话中可选的连接目标 +- 用户可以激活一个目标并得到明确成功 / 失败状态 +- 当前激活连接在 `Connections`、`Inspector`、`Status & Activity` 中保持一致 +- 失败连接不会只闪现一次错误,而是持续显示可读问题 + +### Schema Browser 验收 + +当以下条件成立时,schema 浏览流程可接受: + +- 激活连接后,用户可以浏览 schema 或等价顶层对象 +- 用户可以进一步浏览 table / view 和 column +- 当前浏览结果与激活连接上下文一致 +- 空 schema / 空表路径给出明确反馈,而不是空白区域 + +### Query 验收 + +当以下条件成立时,query 流程可接受: + +- 用户可以在 editor 中编写或修改 SQL +- 用户可以触发执行并看到执行中状态 +- 成功、有结果、空结果、失败这四类结果可以一眼区分 +- 失败时错误摘要在 `Inspector` 或 `Status & Activity` 保持可见 +- TUI 不要求首版拥有高级编辑器能力,但必须保证基本输入和重复执行顺畅 + +### Results 验收 + +当以下条件成立时,结果浏览可接受: + +- 结果区域能展示列头和结果行 +- 空结果不会被误判成执行失败 +- 结果较长时仍有基本浏览能力 +- 当前结果与最近一次成功执行保持一致 + +### Export 验收 + +当以下条件成立时,导出流程可接受: + +- 用户可以从当前结果集触发导出 +- 用户能明确知道导出格式和输出路径 +- 导出成功和失败有清晰反馈 +- 导出失败不会伪装成成功 + +## 11. 成功标准 + +`dbtool-tui-v1` 在产品上可以视为首版成立,当且仅当: + +- 用户能在一个终端会话里完成连接切换、schema 浏览、query 执行、结果查看和导出 +- Frontend、Backend、QA 对 TUI 范围没有歧义 +- TUI 明确复用 `db-app` 契约,而不是解析 CLI 文本 +- CLI、TUI、GUI 的边界没有被混淆 +- QA 可以独立建立 TUI 验收矩阵,而不是借用 CLI 通过来替代 TUI 通过 + +## 12. 给 CTO 的交接说明 + +当前 PM 决策是: + +- `dbtool-tui-v1` 的目标不是 shell baseline 本身,而是“单会话终端数据库工作台” +- 当前必须优先围绕真实连接、schema、query、results、export 五段流程收口 +- 不应把持久化连接管理、多标签页和高级编辑器能力塞进首版 +- `db-app` 是 TUI 的唯一共享执行入口,不应绕过 + +CTO 后续拆解应围绕: + +- 连接激活路径 +- schema browser 接入 +- query / results / export 接入 +- 错误与状态一致性 +- QA 回归闭环 diff --git a/TUI_REGRESSION_CHECKLIST.md b/TUI_REGRESSION_CHECKLIST.md index 20dcdf6..99cee6a 100644 --- a/TUI_REGRESSION_CHECKLIST.md +++ b/TUI_REGRESSION_CHECKLIST.md @@ -11,7 +11,7 @@ ## 1. Shell baseline 回归(当前 runner 可执行) -- [ ] `printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` +- [ ] `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` - [ ] 确认退出码为 `0` - [ ] 确认日志包含 `Connections` - [ ] 确认日志包含 `Schema Browser` @@ -22,11 +22,11 @@ - [ ] 确认日志包含 `Ready` - [ ] 确认日志包含 `Execution Idle` - [ ] 确认日志包含静态连接 / schema / result 示例文本 -- [ ] `printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` +- [ ] `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/tui-small.log` - [ ] 确认退出码为 `0` - [ ] 确认日志包含 `Terminal Too Small` - [ ] 确认日志包含 `Resize the terminal` -- [ ] 明确记录:`dbtool-tui --help` 不是有效的非交互 smoke +- [ ] 明确记录:`dbtool-tui --help` 不是有效的非交互 smoke;若已重建源码,应输出 TTY-required 提示 ## 2. Shell 交互回归(静态 / 示例数据,不等于 live) @@ -39,12 +39,13 @@ - [ ] `Schema Browser` 焦点下 `Enter`:ready schema 展开 / 折叠;empty / restricted schema 给出明确反馈 - [ ] `Query Editor` 焦点下 `[` / `]`:切换静态 query 草稿 - [ ] `i`:进入插入模式并可录入字符 / 换行 -- [ ] `Enter`:触发静态 query 状态轮转或运行占位逻辑 +- [ ] `Enter`:在 `Query Editor` 焦点下触发查询执行;插入模式下只插入换行 - [ ] 结果区可区分 success / empty / error - [ ] `Results` 焦点下 `[` / `]`:宽表列分页 - [ ] `Results` 焦点下 `↑` / `↓`:结果行移动 -- [ ] `l` / `e` / `r`:状态切换 -- [ ] `Esc`:恢复默认工作台 +- [ ] `r`:同时清空 query / export feedback,并恢复 `Ready` +- [ ] 查询运行中按 `x` / `j`:界面提示等待当前查询完成 +- [ ] `Esc`:导航模式下恢复默认工作台;插入模式下退出到 navigate mode - [ ] `q`:稳定退出并释放 alternate screen ## 3. 第二阶段 live integration 回归(仅在 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` 就绪后执行) @@ -94,3 +95,8 @@ - [ ] live integration 回归:等待 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` - [ ] CI smoke:仓库尚未覆盖 `dbtool-tui` - [ ] release artifact smoke:仓库尚未为 `dbtool-tui` 建立产物路径 + +## 当前键位一致性说明 + +- 当前实现不存在 `l` / `e` 的状态切换快捷键;若历史记录出现该表述,应视为旧文档残留而不是当前行为。 +- 当前恢复语义固定为:`Esc` 回稳定态、`r` 清反馈、`Enter` 依焦点执行、插入模式下 `Enter` 不执行查询。 diff --git a/TUI_SMOKE_RUNBOOK.md b/TUI_SMOKE_RUNBOOK.md new file mode 100644 index 0000000..8b8804b --- /dev/null +++ b/TUI_SMOKE_RUNBOOK.md @@ -0,0 +1,85 @@ +# dbtool-tui TTY Smoke Runbook + +## Contract + +- `dbtool-tui` 是交互式 TUI;启动验证必须提供真实 TTY。 +- 当前最小可重复入口是 `scripts/tui/smoke-tty.sh `。 +- `dbtool-tui --help`、`dbtool-tui --version` 不是有效的 TUI smoke 替代路径。 +- 非 TTY 启动当前应退出 `1`,并输出明确的 TTY-required 提示。 + +## 前置条件 + +- Linux runner,且可用 `script`(util-linux)。 +- 已有可执行二进制: + - 源码环境:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` + - 无 Rust 工具链环境:直接使用 `./target/debug/dbtool-tui` +- 若当前 runner 的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 为空字符串,裸 `cargo` 会直接失败;请按上面的显式 override 执行。 + +## 最小入口 + +正常终端尺寸: + +```bash +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui +grep -aq "Workspace" /tmp/dbtool-tui-smoke.log +grep -aq "Loading" /tmp/dbtool-tui-smoke.log +``` + +小终端降级: + +```bash +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.log +grep -aq "Terminal Too Small" /tmp/dbtool-tui-small.log +grep -aq "Resize the terminal" /tmp/dbtool-tui-small.log +``` + +## PostgreSQL / MySQL live path + +- 当前 runner 的可达 network path 是 `host.docker.internal:55432`(PostgreSQL)与 `host.docker.internal:53306`(MySQL),不是 `127.0.0.1`。 +- 当前 runner 即使缺少 `docker compose` 子命令,也可通过 `scripts/tui/demo-stack.sh` 与 bootstrap fallback 建立同一套 demo 数据。 +- 当前推荐的单命令复验入口: + +```bash +export DBTOOL_PASSWORD=dbtool +scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui +scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui +``` + +- 若只想准备 demo stack 与 seed 数据: + +```bash +scripts/tui/demo-stack.sh up +scripts/tui/demo-stack.sh seed +scripts/tui/demo-stack.sh status +``` + +## 非 TTY 预期行为 + +```bash +./target/debug/dbtool-tui --help +``` + +2026-03-28 当前源码重建后二进制实测行为: + +- 退出码:`1` +- `stderr`: + - `dbtool-tui requires an interactive TTY on stdin and stdout.` + - `Detected stdin TTY: false` + - `Detected stdout TTY: false` + - `Use \`scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui\` for repeatable smoke validation.` + - `` `--help` is not a supported TUI smoke path. `` + +这条路径的意义是确认“限制被清楚记录”,不是把 `--help` 视为 smoke 成功。 + +## QA 记录要求 + +- 记录二进制路径、命令、终端尺寸、退出码、日志路径。 +- 正常尺寸至少确认日志里出现 `Workspace`、`Loading`。 +- 小终端路径至少确认日志里出现 `Terminal Too Small`、`Resize the terminal`。 +- network live 路径至少记录目标数据库、endpoint、导出路径和日志路径。 +- 若源码不可重建,要明确记为环境 blocker,不得伪造成产品失败。 + +## CI 约束 + +- 当前仓库还没有正式接入 `dbtool-tui` 的 GitHub Actions smoke。 +- 后续 CI 若补齐,应直接复用 `scripts/tui/smoke-tty.sh`,不要重新发明另一套启动口径。 diff --git a/TUI_TEST_STRATEGY.md b/TUI_TEST_STRATEGY.md index f3a0f76..596d806 100644 --- a/TUI_TEST_STRATEGY.md +++ b/TUI_TEST_STRATEGY.md @@ -10,37 +10,47 @@ ### 已存在的共享资产 -- `apps/tui/src/main.rs`:TUI 主实现,当前包含静态连接、schema、query、results 示例状态 +- `apps/tui/src/main.rs`:TUI 主实现,当前已接入 `sqlite-local` 与 PostgreSQL / MySQL demo profile 的真实 connect / inspect / query / export 路径 - `apps/tui/README.md`:当前交互范围与产品/体验说明 - `TUI_ACCEPTANCE_CHECKLIST.md`:阶段 2 分层验收矩阵 - `TUI_REGRESSION_CHECKLIST.md`:阶段 2 分层回归清单 - `plans/2026-03-27-dbtool-tui-v1-phase-2-scope.md`:CTO 第二阶段范围与 gating - `backlog/2026-03-27-dbtool-tui-v1-phase-2-issues.md`:owner-backed issue 分解 +- `scripts/tui/demo-stack.sh`:无 `docker compose` 子命令时的 demo stack / seed 统一入口 +- `scripts/tui/live-network-smoke.sh`:PostgreSQL / MySQL 的 TTY live smoke 入口 ### 已发现的测试资产 -- `apps/tui/src/main.rs` 内已有 5 个内联测试,覆盖: +- `apps/tui/src/main.rs` 内当前有 9 个内联测试,覆盖: - query 草稿切换 - editor `Enter` 触发 running - empty query 完成态 - results 宽表列分页 - insert mode 录入 + - export 成功态映射 + - 非 TTY 提示文案 + - TTY 检测辅助函数 + - connection-qualified query draft 生成 - 当前存在可执行的预构建二进制:`target/debug/dbtool-tui` +- 当前最小可重复 smoke 入口:`scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` -### 2026-03-27 已执行证据 +### 2026-03-27 ~ 2026-03-31 已执行证据 - TTY shell smoke:`./target/debug/dbtool-tui` 在 `120x40` 下可启动、渲染并以 `q` 退出,退出码 `0` - 小终端降级:同一二进制在 `90x20` 下显示 `Terminal Too Small` 与 `Resize the terminal`,退出码 `0` -- 非交互限制:`./target/debug/dbtool-tui --help` 在当前无 TTY 场景下返回 `No such device or address` +- 源码重建:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` 直接通过 +- 源码测试:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo test -p dbtool-tui` 直接通过,`9` 个测试全绿 +- 非交互限制:源码重建后的 `./target/debug/dbtool-tui --help` 在无 TTY 场景下返回退出码 `1`,并明确提示需要交互式 TTY 以及 `scripts/tui/smoke-tty.sh` +- PostgreSQL live smoke:`scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` 直接通过 +- MySQL live smoke:`scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` 直接通过 ### 当前缺口 -- 当前 runner 默认无 `cargo` / `rustc` 在 PATH -- 虽存在离线 Rust toolchain,但源码重建 / 测试仍被系统缺少 `cc` / `zig cc` 阻塞 -- 仓库暂无 TUI 专属 smoke runbook +- 当前 runner 默认裸 `cargo` 会因为空的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 环境变量直接失败;runbook 必须显式写出 override - `.github/workflows/release-smoke.yml` 当前只覆盖 `dbtool` -- 当前无 TUI 专属 demo / seed / Docker 路径 -- 第二阶段 live integration 仍未整体完成:`[CMP-34](/CMP/issues/CMP-34)` 仍在 `in_progress`,`[CMP-35](/CMP/issues/CMP-35)` 仍待宿主机回填,而 `[CMP-36](/CMP/issues/CMP-36)` 已完成宿主机侧验收 +- 当前已建立 PostgreSQL / MySQL live happy path,但仍缺 failure / empty / restricted / browse-stability 的 runner 直证 +- release artifact / packaged binary 仍缺真实 smoke +- 状态恢复与键盘一致性需要单独留痕,避免 QA 按旧文档复核不存在的快捷键 ## 测试分层 @@ -48,24 +58,25 @@ - 目标:确认实现可重建、内联测试可运行 - 当前命令: - - `cargo build -p dbtool-tui` - - `cargo test -p dbtool-tui` + - `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` + - `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo test -p dbtool-tui` - 当前判断: - - 这是必要层,但当前 runner 不能给出通过结论 - - 缺 `cc` / `zig cc` 时,应记录为环境 blocker,不能伪造成产品失败,也不能反向伪造成已通过 + - 这是必要层,且 2026-03-28 当前 runner 已给出直接通过结论 + - 默认裸 `cargo` 失败仍应记为环境口径风险,不能误写成产品失败 ### 2. TTY shell smoke - 目标:最低成本确认 TUI 可启动、可渲染、可退出、可在小终端降级 - 当前建议: - - 使用 `script` + `stty` 固化 TTY 启动路径 + - 使用 `scripts/tui/smoke-tty.sh` 固化 TTY 启动路径 - 用预构建二进制先取证,再视环境补做源码重建后的同路径复核 - 注意: - `dbtool-tui` 不是普通 CLI;不要用 `--help` 替代交互 smoke + - 共享执行口径收敛到 `TUI_SMOKE_RUNBOOK.md` -### 3. Shell 交互回归(静态 / 示例数据) +### 3. Shell 交互回归(当前可见工作区) -- 目标:验证当前 shell 已实现的交互骨架,但不把示例状态误记为 live 能力 +- 目标:验证当前 shell 已实现的交互骨架,但不把当前可见工作区直接误记为完整的 network-live 能力 - 范围: - 视图切换 - 焦点切换 @@ -73,16 +84,16 @@ - 静态 schema / object 浏览 - 查询草稿切换、插入模式、运行占位、结果分页 - 错误 / 空状态 / 恢复路径 + - `Esc` / `r` / `Enter` / `[` / `]` 的键位一致性 - 输出要求: - 明确标记“这是 shell 示例状态验证,不是 shared app live 数据验证” ### 4. 第二阶段 live integration 验收 -- 目标:在真实 shared app 接线落地后,对连接、inspect、query、results、export 给出验收判断 -- 前置条件: - - `[CMP-34](/CMP/issues/CMP-34)` 完成并冻结契约 - - `[CMP-35](/CMP/issues/CMP-35)` 完成真实连接与 schema browser 接线 - - `[CMP-36](/CMP/issues/CMP-36)` 完成真实 query / results / export 接线 +- 目标:在当前已存在的 runner-side live path 之上,对连接、inspect、query、results、export 给出更完整的验收判断 +- 当前已具备的 happy-path 入口: + - `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` + - `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` - 必验路径: - 真实连接 `loading` / `success` / `failure` - 真实 inspect ready / empty / restricted @@ -109,13 +120,13 @@ ## 当前优先级 1. 保住 shell baseline 证据,不让第二阶段重构把它冲掉 -2. 等待 `[CMP-34](/CMP/issues/CMP-34)` 冻结 shared app 契约 -3. 在 `[CMP-35](/CMP/issues/CMP-35)` / `[CMP-36](/CMP/issues/CMP-36)` 完成后补 live integration 验收 +2. 在已建立的 live happy path 上补 failure / empty / restricted / browse-stability 证据 +3. 补 TUI CI / packaged-binary / release smoke 4. 最后再补 TUI 专属 CI / release / runbook ## 当前验收判断 -- shell baseline:有预构建二进制级别的可重复 smoke,属“有条件通过” -- 源码层验证:当前 runner blocked,不能给通过结论 -- 第二阶段 live integration:当前 blocked,不能给通过结论 -- 发布前状态:未就绪,因为 `dbtool-tui` 仍缺 CI、release artifact smoke 和 live demo 路径 +- shell baseline:有预构建二进制级别的可重复 smoke,属“通过” +- 源码层验证:当前 runner 已通过,但需要在 runbook 中固化 cargo 环境变量 override +- 第二阶段 live integration:当前 runner 内 PostgreSQL / MySQL demo happy path 已通过,但 failure / empty / restricted / browse-stability 仍未补齐 +- 发布前状态:未就绪,因为 `dbtool-tui` 仍缺 CI 与 release artifact smoke diff --git a/USABLE_ACCEPTANCE_CHECKLIST.md b/USABLE_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..4cd4d5b --- /dev/null +++ b/USABLE_ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,153 @@ +# dbtool-usable-v1 验收检查表 + +## 当前结论 + +- 当前结论:`no-go`。 +- 原因 1:CLI 已有较强真实使用证据,但 `dbtool-usable-v1` 不能只按 CLI 通过来判定整体通过。 +- 原因 2:TUI 已形成 PostgreSQL / MySQL demo live happy path,但仍缺 failure / empty / restricted / browse-stability 等更广覆盖证据。 +- 原因 3:发布链路仍缺 GitHub macOS / Windows runner 与 packaged binary 的真实执行证据。 +- 当前统一台账见 `USABLE_EVIDENCE_LEDGER.md`,当前发布门槛结论与模板见 `USABLE_RELEASE_GATE.md`。 + +## 与 PM / CTO 的当前对齐依据 + +- PM 验收边界:`PRODUCT_REQUIREMENTS.md`、`TUI_PRODUCT_REQUIREMENTS.md`、`DATABASE_SUPPORT_MATRIX.md` +- CTO 技术边界:`CTO_REQUIREMENTS_HANDOFF.md`、`TUI_BACKEND_CONTRACT.md` +- 当前状态说明:usable-v1 的统一 QA 结论已可建立;截至 2026-03-31,直接 blocker 已不再是 “TUI 缺 live-path”,而是 live 覆盖深度与 release evidence 仍未收口。 + +## TUI 分层判定规则 + +| 层级 | 允许结论 | 当前结论 | 备注 | +| --- | --- | --- | --- | +| shell baseline | `pass` / `blocked` | `pass` | 只判断启动、布局、焦点、TTY 契约与稳定退出 | +| `sqlite-local` local live | `partial` / `blocked` | `partial` | 只能证明本地 SQLite 单会话工作流成立,不能代表跨库可交付 | +| PostgreSQL / MySQL network live | `pass` / `blocked` | `pass` | 2026-03-31 当前 runner 已直接通过 PostgreSQL / MySQL demo live smoke | + +- network live 已不再是当前主 blocker,但单条 happy path 不能直接上抬成 usable-v1 整体 `pass`。 +- `sqlite-local` 真实路径应被保留为正向证据,但结论必须是 `partial`,不能上抬成整体完成。 + +## 当前测试 / Smoke / 手工验证盘点 + +### 自动化测试资产 + +- 已观察到 Rust 内联测试共 `53` 个: + - `apps/cli/src/main.rs`:`21` + - `apps/tui/src/main.rs`:`9` + - `crates/db-app/src/lib.rs`:`6` + - `crates/db-config/src/lib.rs`:`3` + - `crates/db-core/src/lib.rs`:`4` + - `crates/db-drivers/src/lib.rs`:`10` +- 2026-03-31 当前 runner 已复核测试资产盘点,并通过用户态 Rust toolchain + `zig cc` / `zig ar` 直接复跑 `cargo test --workspace`。 + +### Smoke / 发布资产 + +- 运行型 smoke:`SMOKE_RUNBOOK.md` +- 发布型 smoke:`RELEASE_RUNBOOK.md`、`.github/workflows/release-smoke.yml`、`scripts/release/smoke-binary.sh` +- Demo / seed / Docker 资产:`docker-compose.demo.yml`、`examples/scripts/bootstrap-postgres.sh`、`examples/scripts/bootstrap-mysql.sh`、`examples/scripts/bootstrap-sqlite.sh` + +### 本 heartbeat 的直接执行证据 + +- `bash -n` 已通过:三份 bootstrap 脚本、`scripts/release/smoke-binary.sh` 与 `scripts/tui/smoke-tty.sh` +- `./examples/scripts/bootstrap-sqlite.sh` 已通过;当前 runner 使用 `node` fallback seed +- `./target/release/dbtool --help`、`--version`、SQLite `connect -> inspect -> query -> export` 已通过 +- SQLite failure-path 已复核:missing file、non-db file、bad SQL、export overwrite guard +- CLI JSON 结构化结果已复核: + - validation error 返回 `status=error`、`state=error` + - 空结果查询返回 `status=success`、`state=empty` +- `scripts/release/smoke-binary.sh ./target/release/dbtool` 已通过 +- `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 40 120 /tmp/qa-dbtool-tui.log` 已通过 +- `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/qa-dbtool-tui-small.log` 已通过 +- `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui /tmp/dbtool-tui-postgres-live-qa.log` 已通过 +- `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui /tmp/dbtool-tui-mysql-live-qa.log` 已通过 +- PostgreSQL live smoke 日志已直接观察到: + - `Validation: Connected` + - `3 rows returned` + - `3 rows exported to /tmp/dbtool-tui-reporting-postgres-account-ticket-summary-sql.csv` +- MySQL live smoke 日志已直接观察到: + - `Validation: Connected` + - `3 rows returned` + - `3 rows exported to /tmp/dbtool-tui-orders-mysql-account-ticket-summary-sql.csv` +- `docker exec -i dbtool-cli-v1-postgres-1 psql ... < examples/fixtures/postgres/init.sql` 已通过 +- `docker exec -i dbtool-cli-v1-mysql-1 mysql ... < examples/fixtures/mysql/init.sql` 已通过 +- 通过 sidecar container 进入 `dbtool-cli-v1_default` 网络后,已直接执行 PostgreSQL / MySQL failure-path: + - PostgreSQL 错误密码返回 `error.kind=authentication` + - PostgreSQL 不可达端口返回 `error.kind=connection` + - PostgreSQL 坏 SQL 返回 `error.kind=query` + - MySQL 错误密码返回 `error.kind=authentication` + - MySQL 不可达端口返回 `error.kind=connection` + - MySQL `--file examples/sql/mysql/failing_query.sql` 在最新 sidecar 复跑中已返回 `error.kind=query` + +## 真实使用验收矩阵 + +| 领域 | 真实场景 | 通过标准 | 当前证据 | 结论 | +| --- | --- | --- | --- | --- | +| CLI / SQLite | QA 用本地 SQLite 文件完成 `connect -> inspect -> query -> export` | 命令可重复执行,输出可读,失败路径可解释 | 2026-03-28 本地重跑 `bootstrap-sqlite`、`target/release/dbtool --help`、`connect`、`inspect`、`query`、CSV / JSON `export` 均通过 | `pass` | +| CLI / SQLite failure | 本地错误文件、坏文件、坏 SQL、导出覆盖 | 非零退出码,错误信息可行动 | 2026-03-28 本地重跑 missing file、non-db file、bad SQL、export overwrite;均给出明确错误 | `pass` | +| CLI / PostgreSQL | 技术操作者在 Docker 宿主机完成 happy path | `connect`、`inspect`、`query` 可跑通且有可审计输出 | 2026-03-26 `CMP-17` 宿主机评论已回填 happy-path smoke | `partial` | +| CLI / MySQL | 技术操作者在 Docker 宿主机完成 happy path 与 Unicode 导出 | `connect`、`inspect`、`query`、`export` 可跑通,中文和 emoji 不损坏 | 2026-03-26 `CMP-17` happy path;2026-03-27 `CMP-23` patched-run 证据已覆盖字符集变量、官方 CLI、`dbtool query`、`dbtool export` | `partial` | +| CLI / 跨库一致性 | PostgreSQL / MySQL / SQLite 共享同一 mental model | connect / inspect / query / export 一致,差异有文档 | `README.md`、`PRODUCT_REQUIREMENTS.md`、`DATABASE_SUPPORT_MATRIX.md` 已定义差异;CLI 侧证据较强 | `partial` | +| TUI / shell baseline | 用户能进入工作台、看懂状态、稳定退出 | 六区布局、焦点切换、尺寸降级、退出稳定 | 2026-03-28 本地重跑 `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` 的正常终端与小终端路径,均通过 | `pass` | +| TUI / sqlite-local live path | 用户能在单会话内看到连接、schema、draft、结果与导出反馈 | `sqlite-local` connect / inspect / query / export 全链路可复核 | 当前共享文档与 `apps/tui/README.md` 已定义路径,但本轮仅直接观察到启动与状态骨架 | `partial` | +| TUI / PostgreSQL & MySQL live path | 用户能在 TUI 内真实激活并使用网络数据库 | 激活连接、浏览对象、运行查询、查看错误或结果 | 2026-03-31 当前 runner 直接通过 PostgreSQL / MySQL live smoke;日志已见连接成功、schema 加载、查询成功与导出成功 | `pass` | +| 发布 / Linux artifact | 非源码路径可被 QA 复核 | artifact 解包、`--help`、`--version`、smoke 脚本通过 | 既有 `ACCEPTANCE_CHECKLIST.md` 已记录 Linux artifact smoke | `pass` | +| 发布 / macOS & Windows artifact | 非 Linux 用户也能获得同等级发布证据 | 实际 runner 执行成功,日志可审计 | 当前仓库仅有 workflow 与 runbook,缺真实执行证据 | `blocked` | + +## 本轮直接执行证据 + +- `./examples/scripts/bootstrap-sqlite.sh` +- `./target/release/dbtool --help` +- `./target/release/dbtool --version` +- `./target/release/dbtool connect --driver sqlite --path examples/tmp/dbtool-demo.sqlite` +- `./target/release/dbtool inspect --driver sqlite --path examples/tmp/dbtool-demo.sqlite --schema main` +- `./target/release/dbtool inspect --driver sqlite --path examples/tmp/dbtool-demo.sqlite --schema main --table tickets` +- `./target/release/dbtool query --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/happy_path_query.sql` +- `./target/release/dbtool export --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/export_query.sql --format csv --output /tmp/dbtool-export.csv` +- `./target/release/dbtool export --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/export_query.sql --format json --output /tmp/dbtool-export.json` +- `./target/release/dbtool connect --driver sqlite --path examples/tmp/does-not-exist.sqlite` +- `./target/release/dbtool connect --driver sqlite --path /tmp/not-a-db.txt` +- `./target/release/dbtool query --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/failing_query.sql` +- `./target/debug/dbtool query --driver sqlite --path examples/tmp/dbtool-demo.sqlite --sql "SELECT id FROM tickets WHERE status = ?1" --param missing --result-format json` +- `./target/debug/dbtool query --driver sqlite --path /tmp/notfound.sqlite --sql '' --result-format json` +- 二次串行执行 `export` 到同一路径,确认默认拒绝覆盖,错误为 `output file ... already exists; pass --overwrite to replace it` +- `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 40 120 /tmp/qa-dbtool-tui.log` +- `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/qa-dbtool-tui-small.log` + +## PostgreSQL / MySQL failure-path 补证最短闭环 + +1. 在 Docker-capable 宿主机按 `QA_RUNTIME_ENVIRONMENT.md` 拉起 `docker compose -f docker-compose.demo.yml up -d postgres mysql`,并执行两份 bootstrap 脚本。 +2. 按 `HOST_FAILURE_PATH_CHECKLIST.md` 的顺序执行 6 个场景,记录格式继续使用 `FAILURE_PATH_EVIDENCE_TEMPLATE.md`: + - `PG-CONN-AUTH-001` + - `PG-CONN-NET-001` + - `PG-QUERY-SQL-001` + - `MY-CONN-AUTH-001` + - `MY-CONN-NET-001` + - `MY-QUERY-SQL-001` +3. 每条证据都必须留:UTC 时间、操作者、宿主机、execution path、binary path、`--version`、commit SHA、branch、完整命令、退出码、关键 JSON / 错误摘要、最终 verdict。 +4. 先把原始执行包回填到对应 issue / heartbeat 评论,再同步更新 `USABLE_EVIDENCE_LEDGER.md` 与 `USABLE_RELEASE_GATE.md`。 +5. 若 `status` / `state` / `error.kind` 任何一项偏离模板预期,不把它记成“补证完成”,而是单独拆 defect issue。 +6. 如需减少人工抄录,可在宿主机直接运行 `scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failure-path-evidence`;当前 helper 会自动校验 `status` / `state` / `error.kind`、traceability 完整性,并对可检测到的密码泄露模式直接判 `fail`,但仍需逐条人工复核生成的 markdown 包。 +7. 2026-03-28 当前 QA 容器虽已可访问 Docker daemon,但仍不能直接访问 `127.0.0.1:55432/53306` 或数据库容器 IP;当前有效执行路径是 sidecar container 加入 `dbtool-cli-v1_default` 网络后运行 `dbtool`。 + +## 产品 blocker + +- `dbtool-usable-v1` 还不能把 TUI 视为“已完整签收”的跨库操作台;当前已覆盖 live happy path,但覆盖仍偏窄。 +- TUI 的 PostgreSQL / MySQL 真实激活、基础 schema 加载、查询与导出已进入可验状态。 +- TUI 的 failure / empty / restricted / browse-stability 仍缺最新直接 runner 证据。 +- PostgreSQL / MySQL 的 failure-path 已有最新直接执行证据,且最新 sidecar 复验已确认 6 个场景全部满足模板预期。 + +## 环境 / 证据 blocker + +- 当前 QA runner 已能访问 `docker` daemon,但仍缺 `docker compose` 子命令。 +- 当前 QA runner 不能直接访问 `127.0.0.1:55432/53306` 或数据库容器 IP;TUI 当前有效入口是 `host.docker.internal` + TTY smoke 脚本。 +- GitHub macOS / Windows release runner 仍缺真实执行日志与产物 smoke 证据。 +- release artifact / packaged binary 的 binary path 与 commit SHA / branch 仍待补齐,可追溯性不足。 + +## 回归风险 + +- MySQL UTF-8 / collation 回归会直接破坏“真实可用”的跨库承诺。 +- TUI 若只保留 shell 演示而没有真实网络连接路径,会造成“能演示但不可交付”的误判。 +- 发布流程若只有 workflow 文件而没有真实 runner 证据,容易把“声明可发布”误当成“已验证可发布”。 + +## 当前发布判断 + +- 当前不能宣布 `dbtool-usable-v1` 通过验收。 +- 当前更准确的结论是:CLI 已经具备较强可用性证据;TUI 已具备 demo live happy path,但尚未完成更广的真实跨库验收;发布证据链仍不完整。 diff --git a/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md b/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md new file mode 100644 index 0000000..f57b759 --- /dev/null +++ b/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md @@ -0,0 +1,253 @@ +# `dbtool-usable-v1` Shared App / CLI Contract Snapshot + +日期:2026-03-27 +作者:Senior Backend Engineer +对应 issue:`CMP-46` + +## 1. 目标 + +本文从 `dbtool-usable-v1` 视角冻结当前 `crates/db-app` 与 `apps/cli` 的共享契约、错误边界与当前 gap。 + +本文不是新的架构提案,只记录当前代码与当前阶段可承诺的 backend 行为边界,供 CTO、Frontend、QA 和 PM 直接引用。 + +## 2. 当前后端分层 + +- `crates/db-core` + - 定义数据库无关的领域对象与基础校验: + - `ConnectionTarget` + - `InspectRequest` + - `QueryRequest` + - `ExportRequest` + - `InspectResult` + - `QueryResult` +- `crates/db-config` + - 定义 `ConnectionProfile` + - 负责 password env 注入与脱敏展示 +- `crates/db-drivers` + - 收敛 PostgreSQL / MySQL / SQLite 的 connect / inspect / query 差异 +- `crates/db-app` + - 是当前唯一共享应用编排入口 + - 对 connect / inspect / query / export 暴露统一结构化结果与统一错误对象 +- `apps/cli` + - 只负责参数解析、stdout/stderr 渲染、退出码 + - 当前不应重新定义 shared app 语义 + +## 3. 领域模型与持久化边界 + +### 3.1 当前领域对象 + +- 连接目标:`ConnectionTarget` +- 连接 profile:`ConnectionProfile` +- inspect 请求:`InspectRequest` +- query 请求:`QueryRequest` +- export 请求:`ExportRequest` +- 共享错误:`db_app::AppError` +- 共享状态: + - `AppOperation` + - `OperationState` + - `AppEvent` + +### 3.2 当前持久化边界 + +- 当前产品仍是单次命令执行模型 +- backend 当前不引入: + - profile 持久化库 + - 历史记录库 + - 本地状态库 + - 后台 daemon / session 存储 +- 当前可见写路径只有: + - 用户显式指定的 export 输出文件 + - 用户显式选择的 SQLite 数据库文件 + +### 3.3 迁移结论 + +- 当前阶段没有 backend 自身的持久化 schema,因此没有迁移脚本或迁移兼容问题 +- usable-v1 当前不需要新增 migration 策略 +- 若未来引入本地状态存储,必须先单独定义存储边界与兼容策略,再进入实现 + +## 4. `db-app` 当前稳定契约 + +### 4.1 Connect + +- 输入:`ConnectionProfile` +- 成功结果:`ConnectResponse` +- 当前状态语义: + - `success` + - 不存在 `empty` + +### 4.2 Inspect + +- 输入:`InspectRequest` +- 成功结果:`InspectResponse` +- `payload.kind`: + - `schemas` + - `tables` + - `columns` +- 当前状态语义: + - 非空集合 => `success` + - 空集合 => `empty` + - 执行失败 => `error` + +### 4.3 Query + +- 输入:`QueryRequest` +- 成功结果:`QueryResponse` +- `result` 字段固定包含: + - `columns` + - `rows` + - `rows_affected` + - `row_count` +- 当前状态语义: + - `rows_affected != null` => command-style success + - 返回至少一行 => `success` + - 零行且 `rows_affected == null` => `empty` + - 执行失败 => `error` + +### 4.4 Export + +- 输入: + - `QueryRequest` + - `ExportRequest` +- 成功结果:`ExportResponse` +- 成功结果固定包含: + - `target` + - `source` + - `export` + - `row_count` +- 当前状态语义: + - 文件写入成功 => `success` + - `row_count == 0` 仍算 success + +## 5. CLI 当前契约 + +### 5.1 命令面 + +- `connect` +- `inspect` +- `query` +- `export` + +### 5.2 输出模式 + +- `--result-format text` +- `--result-format json` + +### 5.3 成功输出 + +- text:面向人工阅读 +- json:返回 envelope + +```json +{ + "status": "success", + "operation": "query", + "state": "success", + "data": {} +} +``` + +### 5.4 当前退出码约定 + +- 成功:`0` +- 当前 CLI 错误统一返回:`2` + +## 6. 错误边界矩阵 + +### 6.1 共享应用层已统一的错误 + +当请求已经进入 `db-app` 后,当前错误会统一收敛为 `AppError`: + +- `validation` +- `connection` +- `authentication` +- `inspect` +- `query` +- `export` + +这些错误在 CLI `--result-format json` 下当前会输出结构化 error envelope,并显式带出 `state: error`。 + +### 6.2 当前实际存在的 CLI 前置错误层 + +在进入 `db-app` 之前,`apps/cli` 仍有一层前置错误来源: + +- CLI 参数缺失 / 非法 +- `ConnectionTarget` 组装失败 +- `ConnectionProfile` 组装失败 +- `InspectRequest` 构造失败 +- `QueryRequest` 构造失败 +- `ExportRequest` 构造失败 +- SQL 文件读取失败 +- password env 缺失 + +但在当前源码下,只要用户显式传入 `--result-format json`,这组前置错误也会统一包裹成 JSON `error` envelope,`kind` 固定为 `validation`,`state` 固定为 `error`。 + +这些错误当前仍是字符串错误,不是 `AppError`,因此不完全受 shared error contract 保护。 + +## 7. 当前 gap / blocker 清单 + +### 7.1 当前 CLI JSON 状态映射 + +- CLI JSON envelope 当前固定包含: + - `status` + - `operation` + - `state` +- 当前映射规则: + - envelope 级成功仍返回 `status: success` + - envelope 级失败仍返回 `status: error` + - 共享应用层状态通过 `state` 暴露: + - `connect`:`success` + - `inspect`:`success | empty` + - `query`:`success | empty` + - `export`:`success` + - 所有错误:`error` + +当前判断: + +- 这让 CLI JSON 回归样本能显式区分 `empty` +- 同时不破坏既有 `status=success/error` 的 envelope 语义 + +### 7.2 运行环境说明 + +- 当前 runner 默认缺少 `cargo` / `rustc` / `cc` +- 本轮通过用户态 Rust toolchain 与 `zig cc` / `zig ar` 包装器完成了源码重建与测试 +- 这属于运行环境债务,不是 backend contract defect + +### 7.3 Blocker 判断 + +- 当前没有新的持久化 blocker +- 当前没有 migration blocker +- 当前没有新的 shared contract blocker +- 当前没有新的 backend contract blocker + +## 8. 本轮验证证据 + +静态核对: + +- `crates/db-core/src/lib.rs` +- `crates/db-config/src/lib.rs` +- `crates/db-drivers/src/lib.rs` +- `crates/db-app/src/lib.rs` +- `apps/cli/src/main.rs` +- `TUI_BACKEND_CONTRACT.md` + +命令复核: + +- `cargo run -q -p dbtool-cli -- inspect --driver sqlite --path /tmp/notfound.sqlite --table accounts --result-format json` + - 实际返回 JSON error envelope,`kind=validation`,`state=error` +- `cargo run -q -p dbtool-cli -- query --driver sqlite --path /tmp/notfound.sqlite --sql '' --result-format json` + - 实际返回 JSON error envelope,`kind=validation`,`state=error` +- `cargo run -q -p dbtool-cli -- connect --driver postgres --host 127.0.0.1 --port 5432 --database demo --username demo --password-env MISSING_ENV --result-format json` + - 实际返回 JSON error envelope,`kind=validation`,`state=error` +- `cargo run -q -p dbtool-cli -- query --driver sqlite --path --sql 'SELECT ticket_id FROM tickets WHERE status = ?1' --param missing --result-format json` + - 实际返回 `status=success` 且 `state=empty` +- `cargo test --workspace` + - 当前 runner 经过用户态 Rust + `zig cc` 补齐后,全工作区测试通过 + +## 9. 当前结论 + +- usable-v1 当前 shared app 主契约可继续冻结在 `db-app` +- 当前 CLI 主体没有重新发明 shared domain 语义 +- 当前源码已经补齐 CLI JSON 模式下的前置 validation error 一致性 +- 当前源码已经补齐 CLI JSON 对共享 `empty` 状态的显式映射 +- 当前 backend 这条 contract snapshot 主线没有新增 open blocker +- 当前 backend 更像进入“保持 snapshot 与源码一致 + 发现 defect 就单独拆单”的阶段 diff --git a/USABLE_EVIDENCE_LEDGER.md b/USABLE_EVIDENCE_LEDGER.md new file mode 100644 index 0000000..1ed8a93 --- /dev/null +++ b/USABLE_EVIDENCE_LEDGER.md @@ -0,0 +1,133 @@ +# dbtool-usable-v1 证据台账 + +## 目的 + +- 将 usable-v1 的测试、smoke、手工验证和发布证据收敛到同一张表。 +- 明确区分 `verified`、`unverified`、`blocked`、`out-of-scope`。 +- 明确区分直接执行证据、宿主机证据和仅文档声明。 + +## 状态定义 + +| 状态 | 含义 | +| --- | --- | +| `verified` | 已有可审计执行证据 | +| `partial` | 已有局部或分层证据,但还不能支撑该领域整体通过 | +| `unverified` | 有资产或路径,但还没有足够执行证据 | +| `blocked` | 理论路径存在,但被环境或前置产品能力阻塞 | +| `out-of-scope` | 不属于 usable-v1 当前承诺 | + +## 证据模式定义 + +| 模式 | 含义 | +| --- | --- | +| `direct-run` | 当前或历史 QA 直接执行并留档 | +| `host-run` | Docker-capable 宿主机或外部 runner 已执行并回填 | +| `doc-only` | 仅观察到文档、脚本或 workflow,尚无执行证据 | + +## TUI 结论映射 + +| 层级 | 当前状态 | 说明 | +| --- | --- | --- | +| TUI / shell baseline | `verified` | 当前已有 TTY 启动、布局与退出直接执行证据 | +| TUI / `sqlite-local` local live | `partial` | 当前已有局部真实工作流与历史 QA 证据,但仍不足以支撑 usable-v1 的跨库交付判断 | +| TUI / PostgreSQL live path | `verified` | 2026-03-31 当前 runner 已直接通过 `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` | +| TUI / MySQL live path | `verified` | 2026-03-31 当前 runner 已直接通过 `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` | + +- `sqlite-local` 证据用于证明 TUI 工作台不只是静态演示。 +- usable-v1 的 TUI 是否通过,不再取决于“是否存在 network live path”,而取决于 live 覆盖是否扩展到失败态、空态、浏览稳定性与发布前复验。 + +## 当前资产盘点 + +### 自动化测试 + +| 位置 | 观察到的测试数 | 当前状态 | 备注 | +| --- | --- | --- | --- | +| `apps/cli/src/main.rs` | `21` | `verified` | 2026-03-28 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | +| `apps/tui/src/main.rs` | `9` | `verified` | 2026-03-31 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | +| `crates/db-app/src/lib.rs` | `6` | `verified` | 2026-03-28 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | +| `crates/db-config/src/lib.rs` | `3` | `verified` | 2026-03-28 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | +| `crates/db-core/src/lib.rs` | `4` | `verified` | 2026-03-28 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | +| `crates/db-drivers/src/lib.rs` | `10` | `verified` | 2026-03-28 已复核计数;当前 runner 复跑 `cargo test --workspace` 通过 | + +### Smoke / 发布 / Demo 资产 + +| 路径 | 作用 | 当前状态 | 备注 | +| --- | --- | --- | --- | +| `SMOKE_RUNBOOK.md` | CLI runtime smoke runbook | `verified` | 路径与输入已可读且已局部执行 | +| `RELEASE_RUNBOOK.md` | artifact smoke / release contract | `verified` | Linux 路径已有执行证据 | +| `.github/workflows/release-smoke.yml` | Linux/macOS/Windows release runner | `unverified` | workflow 存在,但缺 macOS / Windows 实跑证据 | +| `scripts/release/smoke-binary.sh` | 最小二进制 smoke | `verified` | 本 heartbeat 语法检查 + Linux binary 执行通过 | +| `docker-compose.demo.yml` | PostgreSQL / MySQL demo runtime | `partial` | 当前 runner 已有 Docker daemon,但缺 `docker compose`;当前有效路径是复用已运行容器 + sidecar network execution | +| `examples/scripts/bootstrap-postgres.sh` | PostgreSQL seed | `partial` | 语法已验证;2026-03-28 本 heartbeat 已通过等价 `docker exec` 完成 live import | +| `examples/scripts/bootstrap-mysql.sh` | MySQL seed | `partial` | 语法已验证;2026-03-28 本 heartbeat 已通过等价 `docker exec` 完成 live import | +| `examples/scripts/bootstrap-sqlite.sh` | SQLite seed | `verified` | 2026-03-28 本 heartbeat 通过 `node` fallback 实际执行 | +| `scripts/tui/live-network-smoke.sh` | TUI PostgreSQL / MySQL live smoke | `verified` | 2026-03-31 当前 runner 已直接执行 postgres / mysql 两条路径 | + +## 统一证据台账 + +| 领域 | 路径 | 状态 | 证据模式 | 最新证据 | 主要缺口 / 下一步 | +| --- | --- | --- | --- | --- | --- | +| 启动 / CLI 基础 | `target/release/dbtool --help` / `--version` | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 通过 | 需补 GitHub macOS / Windows artifact 同级证据 | +| Demo / SQLite seed | `examples/scripts/bootstrap-sqlite.sh` | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 通过 | 无当前 blocker | +| Demo / PostgreSQL seed | `examples/scripts/bootstrap-postgres.sh` | `partial` | `direct-run` | 2026-03-28 本 heartbeat 已通过等价 `docker exec` 完成 live import | 仍需补齐与 `docker compose` 入口完全同口径的证据 | +| Demo / MySQL seed | `examples/scripts/bootstrap-mysql.sh` | `partial` | `direct-run` | 2026-03-28 本 heartbeat 已通过等价 `docker exec` 完成 live import | 仍需补齐与 `docker compose` 入口完全同口径的证据 | +| Docker 路径 | Docker daemon + `dbtool-cli-v1_default` sidecar execution | `partial` | `direct-run` | 2026-03-28 当前 heartbeat 已直接复用运行中的 demo 容器并完成 sidecar execution | 当前 runner 仍缺 `docker compose`,且 QA 容器不能直接访问映射端口 | +| CLI / SQLite happy path | `connect -> inspect -> query -> export` | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 全链路通过 | 保持回归即可 | +| CLI / SQLite failure path | missing file / non-db / bad SQL / overwrite | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 通过 | 仍可补目录不存在、空结果导出边界 | +| CLI / JSON structured envelope | `--result-format json` 的 `status` / `state` / `error|data` | `verified` | `direct-run` | 2026-03-28 已直接复核 validation error=`state:error`、empty result=`state:empty` | 保持 CLI / shared app 契约一致即可 | +| CLI / PostgreSQL happy path | `connect -> inspect -> query` | `verified` | `host-run` | 2026-03-26 `CMP-17` 宿主机 smoke | 需补 failure-path 证据与 traceability | +| CLI / PostgreSQL failure path | 错误密码 / 网络异常 / 坏 SQL | `verified` | `direct-run` | 2026-03-28 sidecar 直跑已确认 `status=error`、`state=error`、`error.kind=authentication|connection|query` | 当前该线主要保持回归即可 | +| CLI / MySQL happy path | `connect -> inspect -> query` | `verified` | `host-run` | 2026-03-26 `CMP-17` 宿主机 smoke | 需补 traceability | +| CLI / MySQL Unicode / export | `query` / `export` 中文与 emoji | `verified` | `host-run` | 2026-03-27 `CMP-23` patched-run | 持续防回归 | +| CLI / MySQL failure path | 错误密码 / 网络异常 / 坏 SQL | `verified` | `direct-run` | 2026-03-28 最新 sidecar 复跑已确认 auth / connection / file-based bad SQL 都满足 `status=error`、`state=error`、正确 `error.kind` | 当前该线主要保持回归即可 | +| TUI / shell baseline | 启动、布局、焦点、退出 | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 正常终端与小终端 TTY smoke 均通过 | 仍需保持 README 与 smoke 口径一致 | +| TUI / sqlite-local live path | connect / inspect / query / export | `partial` | `direct-run` | 历史 QA 文档已有;本 heartbeat 仅复核启动骨架 | 需按 usable-v1 口径补一轮连续操作证据 | +| TUI / PostgreSQL live path | 网络数据库真实激活 | `verified` | `direct-run` | 2026-03-31 当前 runner 直接通过 `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui`;日志显示 `Validation: Connected`、3 schema entries、`3 rows returned` 与 CSV 导出成功 | 仍需补 failure / empty / restricted / browse-stability 覆盖 | +| TUI / MySQL live path | 网络数据库真实激活 | `verified` | `direct-run` | 2026-03-31 当前 runner 直接通过 `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui`;日志显示 schema 加载、`3 rows returned` 与 CSV 导出成功 | 仍需补 failure / empty / restricted / browse-stability 覆盖 | +| Linux artifact smoke | `scripts/release/smoke-binary.sh ./target/release/dbtool` | `verified` | `direct-run` | 2026-03-28 当前 heartbeat 通过 | 无当前 blocker | +| macOS artifact smoke | `.github/workflows/release-smoke.yml` matrix | `blocked` | `doc-only` | workflow 存在 | 缺真实 runner 日志 | +| Windows artifact smoke | `.github/workflows/release-smoke.yml` matrix | `blocked` | `doc-only` | workflow 存在 | 缺真实 runner 日志 | +| 源码测试执行 | `cargo test --workspace` | `verified` | `direct-run` | 2026-03-28 当前 runner 通过用户态 Rust toolchain + `zig cc` / `zig ar` 复跑并全量通过 | 保持回归即可 | + +## PostgreSQL / MySQL failure-path 补证闭环 + +1. 在 Docker-capable 宿主机按 `QA_RUNTIME_ENVIRONMENT.md` 拉起 `postgres` / `mysql` 并完成 bootstrap。 +2. 优先按 `HOST_FAILURE_PATH_CHECKLIST.md` 的顺序逐条执行 6 个场景;记录格式仍以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准,首次回填可直接套用 `FAILURE_PATH_EVIDENCE_SAMPLE.md`。 +3. 每条记录都必须带: + - UTC 时间 + - 操作者 / 宿主机 + - binary path / `--version` + - commit SHA / branch + - 完整命令 + - 退出码 + - JSON 或错误摘要 + - verdict +4. 回填顺序: + - 先在 issue / 评论里贴原始执行包 + - 再把 `CLI / PostgreSQL failure path`、`CLI / MySQL failure path` 两行改为 `verified` 或继续保留 `blocked` + - 最后同步 `USABLE_RELEASE_GATE.md` +5. 若 `status` / `state` / `error.kind` 偏离模板预期,先拆 defect,再继续台账更新。 + +## Blocker / Risk 清单 + +### 产品 blocker + +- `dbtool-usable-v1` 仍不能把 TUI 视为“已完成签收”的跨库操作台;当前虽已有 PostgreSQL / MySQL live happy path,但 live 覆盖仍偏窄。 +- PostgreSQL / MySQL 的 failure-path 当前已补到直接执行层并通过最新 sidecar 复验;这一条线当前主要保持回归即可。 +- TUI failure / empty / restricted / browse-stability 与 packaged-binary 证据仍未收口。 + +### 环境 / 证据 blocker + +- 当前 QA runner 已有 `docker` daemon,但缺 `docker compose`。 +- 当前 QA runner 已可通过用户态 Rust toolchain + `zig cc` / `zig ar` 复跑源码测试,并可通过 `host.docker.internal` 直接复跑 TUI live smoke;但仍无法按标准 compose/host-port 口径原样复跑。 +- 当前 QA runner 不能直接访问 `127.0.0.1:55432/53306` 或数据库容器 IP。 +- GitHub macOS / Windows release runner 仍缺真实运行日志与产物 smoke 记录。 +- release artifact / packaged binary 的 binary path、commit SHA / branch 仍需补齐追溯性。 + +### Out-of-scope + +- Desktop / Web GUI +- import、migration / schema editing +- 权限 / 用户管理 +- 备份 / 恢复 +- 多连接并发在线工作区 diff --git a/USABLE_PRE_RELEASE_CHECKLIST.md b/USABLE_PRE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..52d1d6a --- /dev/null +++ b/USABLE_PRE_RELEASE_CHECKLIST.md @@ -0,0 +1,49 @@ +# dbtool-usable-v1 发布前检查清单 + +## 当前总判断 + +- 当前状态:`no-go` +- 主因:TUI 虽已具备真实跨库 demo live happy path,但更广的 live 覆盖与跨平台 release 证据仍未闭环 + +## 必须通过 + +- [x] CLI / SQLite 主路径有最新本地运行证据 +- [x] CLI / SQLite 至少一组失败路径有最新本地运行证据 +- [x] CLI / PostgreSQL happy path 有宿主机证据 +- [x] CLI / MySQL happy path 与 Unicode 路径有宿主机证据 +- [x] PostgreSQL / MySQL failure-path 有可审计证据 +- [x] TUI shell baseline 有本地运行证据 +- [x] TUI / `sqlite-local` local live 已有局部证据,但只可记为 `partial` +- [x] TUI 至少一条真实网络数据库 live path 通过 +- [ ] GitHub Linux / macOS / Windows release runner 有真实执行证据 +- [ ] 发布版本、binary path、commit SHA / branch 可追溯 + +## 文档与可重复性 + +- [x] `USABLE_ACCEPTANCE_CHECKLIST.md` 已建立 +- [x] `USABLE_TEST_STRATEGY.md` 已建立 +- [x] `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 与 `FAILURE_PATH_EVIDENCE_SAMPLE.md` 已建立 +- [x] CLI 运行、demo、seed、smoke、release runbook 已存在 +- [ ] TUI usable-v1 级别的完整发布证据仍待补齐 +- [ ] 非开发者按文档独立复核整套 usable-v1 仍未证明 + +## 当前 blocker 分类 + +### 产品 blocker + +- [ ] TUI 仍缺失败连接、empty / restricted schema、empty / error result 与结果浏览稳定性的完整 live 覆盖证据 +- [ ] TUI 当前的 demo live happy path 仍不能单独替代长期可持续使用签收 +- [ ] usable-v1 仍无法以单一产品口径声明“终端里可持续完成跨库任务” + +### 环境 / 证据 blocker + +- [ ] 当前 QA runner 缺 `docker compose`,标准 runbook 仍不能原样复跑 +- [ ] 当前 QA runner 不能直接走 `127.0.0.1:55432/53306`;当前有效入口是 `host.docker.internal` + TTY smoke 脚本 +- [ ] 缺 macOS / Windows runner 实际日志 +- [ ] 缺 release artifact / packaged binary 的 binary path 与 commit SHA / branch 留档 + +## 发布前最后判断规则 + +- 若只补齐文档、未补齐 runner / live evidence,则仍是 `no-go` +- 若 CLI 继续稳定,但 TUI 仍只有 demo 壳层,也仍是 `no-go` +- 只有当 CLI、TUI、发布证据三条线都形成可审计闭环后,才可考虑转为 `go` diff --git a/USABLE_RELEASE_GATE.md b/USABLE_RELEASE_GATE.md new file mode 100644 index 0000000..15995ab --- /dev/null +++ b/USABLE_RELEASE_GATE.md @@ -0,0 +1,103 @@ +# dbtool-usable-v1 发布门槛结论 + +## 当前快照 + +- 日期:2026-03-31 +- QA 结论:`no-go` +- 判断类型:evidence-based gate + +## 当前 gate 判断 + +### 已满足 + +- CLI / SQLite 主路径与关键 failure-path 具备 2026-03-28 最新直接执行证据 +- CLI / PostgreSQL happy path 具备宿主机证据 +- CLI / MySQL happy path 与 Unicode/export 具备宿主机证据 +- TUI shell baseline 具备 2026-03-28 最新直接执行证据 +- TUI / `sqlite-local` local live 已有局部真实工作流证据,但只能记为 `partial` +- TUI / PostgreSQL live demo path 已具备 2026-03-31 当前 runner 直接执行证据 +- TUI / MySQL live demo path 已具备 2026-03-31 当前 runner 直接执行证据 +- Linux artifact smoke 具备 2026-03-28 直接执行证据 + +### 未满足 + +- TUI 仍缺 failure / empty / restricted / result-browsing 等 live 覆盖证据,尚不足以宣布长期可持续使用体验完成 +- TUI release artifact / packaged binary 仍缺真实 smoke 证据 +- GitHub macOS / Windows release runner 仍缺真实执行日志 +- 发布追溯性仍缺 binary path、commit SHA / branch 留档 + +## PostgreSQL / MySQL failure-path 收口规则 + +- 只有当 `HOST_FAILURE_PATH_CHECKLIST.md` / `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 定义的 6 个场景至少各有一条可审计记录时,failure-path blocker 才能从 `未满足` 移除。 +- 每条记录都必须带 UTC 时间、操作者、宿主机、binary path、`--version`、commit SHA、branch、完整命令、退出码、关键输出摘要。 +- 若任一场景的 `status` / `state` / `error.kind` 偏离模板预期,应先按 defect 管理,而不是直接宣称 failure-path 已补齐。 + +## Blocker 分类 + +### 产品 blocker + +- TUI 已不再停留在纯 shell 演示;当前 runner 已直接通过 PostgreSQL / MySQL demo live happy path。 +- 但单条 happy path 还不能等价为“可持续使用”:失败连接、空 schema / restricted schema、空结果 / 错误结果、结果浏览稳定性仍缺完整 runner 证据。 +- usable-v1 还无法以单一产品口径声明“技术操作者可在终端中持续完成跨库任务”。 + +### 环境 / 证据 blocker + +- 当前 QA runner 已有 `docker` daemon,但缺 `docker compose` +- 当前 QA runner 可直接复跑源码测试,并已通过 `host.docker.internal:55432/53306` 完成 TUI live smoke;但标准 `docker compose` runbook 仍无法原样复跑 +- 当前 QA runner 不能直接访问 `127.0.0.1:55432/53306`;当前有效入口是 `host.docker.internal` + TTY smoke 脚本 +- 缺 GitHub macOS / Windows runner 实跑证据 +- 缺 release artifact / packaged binary 的执行追溯性字段 + +## 最新复验 + +- 2026-03-28 最新 sidecar 复跑已确认 PostgreSQL auth / network / bad-SQL 与 MySQL auth / network / file-based bad-SQL 全部满足 failure-path 模板预期。 +- [CMP-53](/CMP/issues/CMP-53) 已被当前 release binary 复验为修复完成,不再计为活跃 backend blocker。 +- 2026-03-31 当前 runner 已直接执行 `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` 与 `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui`,两条路径都完成连接激活、schema 加载、查询成功与 CSV 导出。 +- 当前 `no-go` 判断继续成立,但原因已收敛为 TUI live 覆盖深度不足、跨平台 release 证据缺口与 release traceability 缺口,而不是“缺少 TUI network live path”或活跃的 CLI backend contract defect。 + +## 通过规则 + +- 只有当 CLI、TUI、release evidence 三条线都形成可审计闭环时,usable-v1 才能从 `no-go` 转为 `go` +- 只补文档、不补执行证据,仍是 `no-go` +- 只证明 CLI 可用、TUI 仍停留在演示态,仍是 `no-go` + +## 结论模板 + +后续发布判断应至少包含以下字段: + +```md +# dbtool-usable-v1 Release Gate + +- Date: +- Reviewer: +- Verdict: `go` | `no-go` +- Evidence Window: +- Binary / Artifact: +- Commit SHA / Branch: + +## Product Gates + +- CLI / SQLite: +- CLI / PostgreSQL: +- CLI / MySQL: +- TUI / shell baseline: +- TUI / network live: + +## Release Gates + +- Linux runner: +- macOS runner: +- Windows runner: +- Artifact smoke: +- Traceability: + +## Blocking Items + +- Product blockers: +- Evidence blockers: + +## Notes + +- Residual risks: +- Follow-up issues: +``` diff --git a/USABLE_TEST_STRATEGY.md b/USABLE_TEST_STRATEGY.md new file mode 100644 index 0000000..e7bc4b0 --- /dev/null +++ b/USABLE_TEST_STRATEGY.md @@ -0,0 +1,104 @@ +# dbtool-usable-v1 测试策略 + +## 目标 + +- 验证“真实技术操作者”能否在当前阶段顺利完成高频数据库工作流。 +- 将 CLI 已成立的能力、TUI 当前能力和发布证据链分层管理,避免混淆“局部通过”和“整体通过”。 +- 对 PM 的可用性边界与 CTO 的技术边界给出可执行 QA 要求。 + +## 当前测试优先级 + +1. CLI 三库核心工作流是否仍成立 +2. 失败路径是否可解释、可复现 +3. TUI 是否已从 shell baseline 进入真实可用路径 +4. 发布链路是否有跨平台可审计证据 + +## 测试分层 + +### 1. CLI 核心工作流 + +- PostgreSQL / MySQL / SQLite 分别验证 `connect -> inspect -> query -> export` +- 优先保留技术操作者最常见的排障与取证路径 +- CLI 是 `usable-v1` 当前最成熟的真实使用入口 + +### 2. 失败路径 + +- 连接失败:错误 host / port / database / user / password、网络不可达、SQLite 文件异常 +- 查询失败:坏 SQL、空结果、权限错误、对象不存在 +- 导出失败:覆盖、目录不存在、无结果集、编码问题 +- 失败路径必须和 happy path 一样保留证据 + +### 3. TUI 分层验收 + +- shell baseline:启动、布局、焦点、状态、终端尺寸降级、退出 +- local live:`sqlite-local` 的 connect / inspect / query / export +- network live:PostgreSQL / MySQL 真实连接激活、错误持久显示、结果浏览与导出 +- 未达到 network live 前,TUI 只能被标记为部分通过 + +### 4. 发布与可交付 + +- Linux / macOS / Windows 需要真实 runner 证据,而不是仅有 workflow 文件 +- release artifact 需验证 `--help`、`--version`、最小 smoke +- README / runbook / smoke 脚本要和实际执行结果一致 + +## 证据规则 + +- 通过项必须保留命令、输入、输出摘要、环境信息 +- 失败项必须保留复现步骤、退出码或错误摘要、预期与实际差异 +- 未执行项必须明确写出原因 +- 环境 blocker 与产品 blocker 分开记,不得混写 + +## 当前基线证据 + +- 源码测试资产:当前仓库可观察到 `52` 个 Rust 内联测试;2026-03-28 当前 runner 已通过用户态 Rust toolchain + `zig cc` / `zig ar` 复跑 `cargo test --workspace` +- CLI / SQLite:2026-03-28 本地 release / debug binary 重跑 happy path 与关键 failure path +- CLI / PostgreSQL:2026-03-26 `CMP-17` 宿主机 happy-path smoke +- CLI / MySQL:2026-03-26 `CMP-17` happy-path smoke + 2026-03-27 `CMP-23` Unicode patched-run +- TUI / shell:2026-03-28 本地 `scripts/tui/smoke-tty.sh` 正常终端与小终端路径通过 +- TUI / deeper live:参考 `apps/tui/README.md`、`TUI_ACCEPTANCE_CHECKLIST.md`,但仍需在 usable-v1 口径下继续补证 + +## Failure-Path 补证闭环 + +1. 执行环境固定为 Docker-capable 宿主机,避免把当前 runner 缺 `docker` 的限制误记成产品失败。 +2. 宿主机先按 `QA_RUNTIME_ENVIRONMENT.md` 完成 compose 启动和 PostgreSQL / MySQL bootstrap。 +3. 优先按 `HOST_FAILURE_PATH_CHECKLIST.md` 执行宿主机检查顺序;记录格式仍以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准,并用 `FAILURE_PATH_EVIDENCE_SAMPLE.md` 校准回填样式。 +4. 每条证据至少断言: + - `status` + - `state` + - `error.kind` + - 无敏感信息泄露 +5. 一轮执行完成后,同时回填: + - 原始 issue 评论 + - `USABLE_EVIDENCE_LEDGER.md` + - `USABLE_RELEASE_GATE.md` +6. 若任何场景的 `error.kind` 与模板不一致,优先拆 defect issue,而不是把台账改成 `verified`。 + +## Backend 验收要求 + +- `CMP-42` 至少需要补齐 PostgreSQL / MySQL 的真实可复验路径,而不只停留在 repo 内的 fixture 或单元测试 +- `db-app` 的 connect / inspect / query / export 契约必须继续稳定,避免 TUI 与 CLI 对同一结果产生不同语义 +- CLI `--result-format json` 的验收不应只看 `status`;对空结果和错误路径还应断言 `state` +- MySQL UTF-8、导出编码和错误分类回归必须继续可由 QA 独立验证 +- 新缺陷必须拆成独立 issue,且附带最小复现步骤 + +## Frontend / TUI 验收要求 + +- `CMP-43` 需要把 TUI 从“可演示”推进到“可连续使用” +- 至少要形成一条真实网络数据库的可复验路径,不能只依赖 `sqlite-local` +- 失败连接、空结果、坏 SQL、导出反馈必须持续可见,不得只给瞬时提示 +- 所有交互都要能由键盘完成,并能被 QA 以固定步骤复核 + +## 目前不应误判为通过的情况 + +- 只有 shell baseline 可启动 +- 只有 README / workflow / runbook,但没有实际执行证据 +- 只有 CLI 通过,而 TUI 仍未进入真实网络数据库路径 +- 只有宿主机口头结论,没有 binary path、commit SHA 或关键输出摘要 + +## 下一轮验证重点 + +- 按 `HOST_FAILURE_PATH_CHECKLIST.md` 补齐 PostgreSQL / MySQL failure-path 6 条场景证据;首次回填可直接参考 `FAILURE_PATH_EVIDENCE_SAMPLE.md` +- 补齐 usable-v1 口径下的 TUI live SQLite 完整证据 +- 追踪 `CMP-42` / `CMP-43` 是否给出可被 QA 直接复用的验收入口 +- 补齐 GitHub macOS / Windows release runner 的真实 smoke 证据 +- 对 PostgreSQL / MySQL failure-path 做最少一轮可审计回归 diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index f895e93..717739d 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -10,9 +10,12 @@ name = "dbtool" path = "src/main.rs" [dependencies] +db-app.workspace = true db-config.workspace = true db-core.workspace = true db-drivers.workspace = true +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" [dev-dependencies] rusqlite.workspace = true diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 8d10231..ba9b93f 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -3,12 +3,16 @@ use std::fs; use std::path::PathBuf; use std::process::ExitCode; +use db_app::{ + self, AppError, ColumnItem, ConnectResponse, ExportResponse, InspectPayload, InspectResponse, + OperationState, QueryResponse, SchemaItem, TableItem, +}; use db_config::ConnectionProfile; use db_core::{ - ColumnSummary, ConnectionTarget, ConnectionTransport, ExportFormat, ExportRequest, - InspectRequest, InspectResult, QueryRequest, QueryResult, QuerySource, TableSummary, + ConnectionTarget, ConnectionTransport, ExportFormat, ExportRequest, InspectRequest, + QueryRequest, QuerySource, }; -use db_drivers::DriverRegistry; +use serde_json::json; fn main() -> ExitCode { match run(env::args().skip(1).collect()) { @@ -38,63 +42,76 @@ fn run(args: Vec) -> Result { return Ok(command_help("connect")); } - let profile = - profile_from_target_args("adhoc-connect", parse_target_args(&args[1..])?)?; - DriverRegistry::connect(&profile.target).map_err(|error| error.to_string())?; + let requested_result_format = requested_result_format(&args[1..]); + let parsed = parse_connect_args(&args[1..]).map_err(|error| { + render_cli_error_output("connect", requested_result_format, error) + })?; + let profile = profile_from_target_args("adhoc-connect", parsed.target) + .map_err(|error| render_cli_error_output("connect", parsed.result_format, error))?; + let response = db_app::connect(&profile) + .map_err(|error| render_error_output(&error, parsed.result_format))?; - Ok(format!("connected {}", profile.redacted_summary())) + render_connect_output(&response, parsed.result_format) } "inspect" => { if has_help_flag(&args[1..]) { return Ok(command_help("inspect")); } - let parsed = parse_inspect_args(&args[1..])?; - let profile = profile_from_target_args("adhoc-inspect", parsed.target)?; - let request = InspectRequest::new(parsed.schema, parsed.table) - .map_err(|error| error.to_string())?; - let result = DriverRegistry::inspect(&profile.target, &request) - .map_err(|error| error.to_string())?; + let requested_result_format = requested_result_format(&args[1..]); + let parsed = parse_inspect_args(&args[1..]).map_err(|error| { + render_cli_error_output("inspect", requested_result_format, error) + })?; + let profile = profile_from_target_args("adhoc-inspect", parsed.target) + .map_err(|error| render_cli_error_output("inspect", parsed.result_format, error))?; + let request = InspectRequest::new(parsed.schema, parsed.table).map_err(|error| { + render_cli_error_output("inspect", parsed.result_format, error.to_string()) + })?; + let response = db_app::inspect(&profile, &request) + .map_err(|error| render_error_output(&error, parsed.result_format))?; - Ok(render_inspect_result(&profile, result)) + render_inspect_output(&response, parsed.result_format) } "query" => { if has_help_flag(&args[1..]) { return Ok(command_help("query")); } - let parsed = parse_query_args(&args[1..])?; - let profile = profile_from_target_args("adhoc-query", parsed.target)?; - let request = query_request(parsed.sql, parsed.file, parsed.params)?; - let result = DriverRegistry::query(&profile.target, &request) - .map_err(|error| error.to_string())?; + let requested_result_format = requested_result_format(&args[1..]); + let parsed = parse_query_args(&args[1..]).map_err(|error| { + render_cli_error_output("query", requested_result_format, error) + })?; + let profile = profile_from_target_args("adhoc-query", parsed.target) + .map_err(|error| render_cli_error_output("query", parsed.result_format, error))?; + let request = query_request(parsed.sql, parsed.file, parsed.params) + .map_err(|error| render_cli_error_output("query", parsed.result_format, error))?; + let response = db_app::query(&profile, &request) + .map_err(|error| render_error_output(&error, parsed.result_format))?; - Ok(render_query_result(&profile, &request, result)) + render_query_output(&response, parsed.result_format) } "export" => { if has_help_flag(&args[1..]) { return Ok(command_help("export")); } - let parsed = parse_export_args(&args[1..])?; - let profile = profile_from_target_args("adhoc-export", parsed.query.target)?; - let request = query_request(parsed.query.sql, parsed.query.file, parsed.query.params)?; + let requested_result_format = requested_result_format(&args[1..]); + let parsed = parse_export_args(&args[1..]).map_err(|error| { + render_cli_error_output("export", requested_result_format, error) + })?; + let profile = profile_from_target_args("adhoc-export", parsed.query.target) + .map_err(|error| render_cli_error_output("export", parsed.result_format, error))?; + let request = query_request(parsed.query.sql, parsed.query.file, parsed.query.params); + let request = request + .map_err(|error| render_cli_error_output("export", parsed.result_format, error))?; let export = ExportRequest::new(parsed.format, parsed.output, parsed.overwrite) - .map_err(|error| error.to_string())?; + .map_err(|error| { + render_cli_error_output("export", parsed.result_format, error.to_string()) + })?; + let response = db_app::export(&profile, &request, &export) + .map_err(|error| render_error_output(&error, parsed.result_format))?; - validate_export_path(&export)?; - - let result = DriverRegistry::query(&profile.target, &request) - .map_err(|error| error.to_string())?; - validate_export_result(&result)?; - write_export(&export, &result)?; - - Ok(format!( - "exported {} rows from {} to {}", - row_count(&result), - profile.redacted_summary(), - export.output_path.display() - )) + render_export_output(&response, parsed.result_format) } other => Err(format!("unknown command `{other}`\n\n{}", root_help())), } @@ -111,11 +128,25 @@ struct TargetArgs { path: Option, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ResultFormat { + #[default] + Text, + Json, +} + +#[derive(Debug)] +struct ConnectArgs { + target: TargetArgs, + result_format: ResultFormat, +} + #[derive(Debug)] struct InspectArgs { target: TargetArgs, schema: Option, table: Option, + result_format: ResultFormat, } #[derive(Debug)] @@ -124,6 +155,7 @@ struct QueryArgs { sql: Option, file: Option, params: Vec, + result_format: ResultFormat, } #[derive(Debug)] @@ -132,6 +164,7 @@ struct ExportArgs { format: ExportFormat, output: PathBuf, overwrite: bool, + result_format: ResultFormat, } #[derive(Clone, Copy, Debug)] @@ -160,20 +193,24 @@ GLOBAL FLAGS:\n -h, --help Show help\n -V, --version Show version\n", fn command_help(command: &str) -> String { match command { "connect" => String::from( - "USAGE:\n dbtool connect --driver [TARGET OPTIONS]\n\n\ -TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + "USAGE:\n dbtool connect --driver [TARGET OPTIONS] [RESPONSE OPTIONS]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n\n\ +RESPONSE OPTIONS:\n --result-format \n", ), "inspect" => String::from( - "USAGE:\n dbtool inspect --driver [TARGET OPTIONS] [--schema ] [--table ]\n\n\ -TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + "USAGE:\n dbtool inspect --driver [TARGET OPTIONS] [--schema ] [--table
] [RESPONSE OPTIONS]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n\n\ +RESPONSE OPTIONS:\n --result-format \n", ), "query" => String::from( - "USAGE:\n dbtool query --driver [TARGET OPTIONS] (--sql | --file ) [--param ...]\n\n\ -TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + "USAGE:\n dbtool query --driver [TARGET OPTIONS] (--sql | --file ) [--param ...] [RESPONSE OPTIONS]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n\n\ +RESPONSE OPTIONS:\n --result-format \n", ), "export" => String::from( - "USAGE:\n dbtool export --driver [TARGET OPTIONS] (--sql | --file ) [--param ...] --format --output [--overwrite]\n\n\ -TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + "USAGE:\n dbtool export --driver [TARGET OPTIONS] (--sql | --file ) [--param ...] --format --output [--overwrite] [RESPONSE OPTIONS]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n\n\ +RESPONSE OPTIONS:\n --result-format \n", ), _ => root_help(), } @@ -195,6 +232,15 @@ fn profile_from_target_args(name: &str, args: TargetArgs) -> Result Result { + let (result_format, target_tokens) = split_result_format_tokens(args)?; + + Ok(ConnectArgs { + target: parse_target_args(&target_tokens)?, + result_format, + }) +} + fn parse_target_args(args: &[String]) -> Result { let mut parsed = TargetArgs::default(); let mut index = 0; @@ -253,14 +299,16 @@ fn parse_target_args(args: &[String]) -> Result { fn parse_inspect_args(args: &[String]) -> Result { let mut schema = None; let mut table = None; + let (result_format, command_tokens) = split_result_format_tokens(args)?; let mut target_tokens = Vec::new(); let mut index = 0; - while index < args.len() { - match args[index].as_str() { + while index < command_tokens.len() { + match command_tokens[index].as_str() { "--schema" => { schema = Some( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --schema"))? .clone(), ); @@ -268,7 +316,8 @@ fn parse_inspect_args(args: &[String]) -> Result { } "--table" => { table = Some( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --table"))? .clone(), ); @@ -276,7 +325,7 @@ fn parse_inspect_args(args: &[String]) -> Result { } flag => { target_tokens.push(String::from(flag)); - if let Some(next) = args.get(index + 1) { + if let Some(next) = command_tokens.get(index + 1) { if !next.starts_with("--") { target_tokens.push(next.clone()); index += 2; @@ -292,6 +341,7 @@ fn parse_inspect_args(args: &[String]) -> Result { target: parse_target_args(&target_tokens)?, schema, table, + result_format, }) } @@ -299,14 +349,16 @@ fn parse_query_args(args: &[String]) -> Result { let mut sql = None; let mut file = None; let mut params = Vec::new(); + let (result_format, command_tokens) = split_result_format_tokens(args)?; let mut target_tokens = Vec::new(); let mut index = 0; - while index < args.len() { - match args[index].as_str() { + while index < command_tokens.len() { + match command_tokens[index].as_str() { "--sql" => { sql = Some( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --sql"))? .clone(), ); @@ -314,7 +366,8 @@ fn parse_query_args(args: &[String]) -> Result { } "--file" => { file = Some(PathBuf::from( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --file"))? .clone(), )); @@ -322,7 +375,8 @@ fn parse_query_args(args: &[String]) -> Result { } "--param" => { params.push( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --param"))? .clone(), ); @@ -330,7 +384,7 @@ fn parse_query_args(args: &[String]) -> Result { } flag => { target_tokens.push(String::from(flag)); - if let Some(next) = args.get(index + 1) { + if let Some(next) = command_tokens.get(index + 1) { if !next.starts_with("--") { target_tokens.push(next.clone()); index += 2; @@ -347,6 +401,7 @@ fn parse_query_args(args: &[String]) -> Result { sql, file, params, + result_format, }) } @@ -354,13 +409,14 @@ fn parse_export_args(args: &[String]) -> Result { let mut format = None; let mut output = None; let mut overwrite = false; + let (result_format, command_tokens) = split_result_format_tokens(args)?; let mut query_tokens = Vec::new(); let mut index = 0; - while index < args.len() { - match args[index].as_str() { + while index < command_tokens.len() { + match command_tokens[index].as_str() { "--format" => { - let raw = args + let raw = command_tokens .get(index + 1) .ok_or(String::from("missing value for --format"))?; format = Some(parse_export_format(raw)?); @@ -368,7 +424,8 @@ fn parse_export_args(args: &[String]) -> Result { } "--output" => { output = Some(PathBuf::from( - args.get(index + 1) + command_tokens + .get(index + 1) .ok_or(String::from("missing value for --output"))? .clone(), )); @@ -380,7 +437,7 @@ fn parse_export_args(args: &[String]) -> Result { } flag => { query_tokens.push(String::from(flag)); - if let Some(next) = args.get(index + 1) { + if let Some(next) = command_tokens.get(index + 1) { if !next.starts_with("--") { query_tokens.push(next.clone()); index += 2; @@ -397,9 +454,61 @@ fn parse_export_args(args: &[String]) -> Result { format: format.ok_or(String::from("missing value for --format"))?, output: output.ok_or(String::from("missing value for --output"))?, overwrite, + result_format, }) } +fn split_result_format_tokens(args: &[String]) -> Result<(ResultFormat, Vec), String> { + let mut result_format = ResultFormat::Text; + let mut tokens = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--result-format" => { + let raw = args + .get(index + 1) + .ok_or(String::from("missing value for --result-format"))?; + result_format = parse_result_format(raw)?; + index += 2; + } + flag => { + tokens.push(String::from(flag)); + if let Some(next) = args.get(index + 1) { + if !next.starts_with("--") { + tokens.push(next.clone()); + index += 2; + continue; + } + } + index += 1; + } + } + } + + Ok((result_format, tokens)) +} + +fn requested_result_format(args: &[String]) -> ResultFormat { + let mut result_format = ResultFormat::Text; + let mut index = 0; + + while index < args.len() { + if args[index] == "--result-format" { + if let Some(raw) = args.get(index + 1) { + if let Ok(parsed) = parse_result_format(raw) { + result_format = parsed; + } + } + index += 2; + continue; + } + index += 1; + } + + result_format +} + fn query_request( sql: Option, file: Option, @@ -438,6 +547,14 @@ fn parse_export_format(raw: &str) -> Result { } } +fn parse_result_format(raw: &str) -> Result { + match raw { + "text" => Ok(ResultFormat::Text), + "json" => Ok(ResultFormat::Json), + _ => Err(format!("unsupported result format `{raw}`")), + } +} + impl TargetArgs { fn into_target(self) -> Result { let kind = self @@ -475,57 +592,188 @@ impl From for db_core::DatabaseKind { } } -fn render_inspect_result(profile: &ConnectionProfile, result: InspectResult) -> String { - match result { - InspectResult::Schemas(schemas) => { +fn render_connect_output( + response: &ConnectResponse, + result_format: ResultFormat, +) -> Result { + match result_format { + ResultFormat::Text => Ok(format!( + "connected {}", + render_target_summary(&response.target) + )), + ResultFormat::Json => render_json_success("connect", response.state(), response), + } +} + +fn render_inspect_output( + response: &InspectResponse, + result_format: ResultFormat, +) -> Result { + match result_format { + ResultFormat::Text => Ok(render_inspect_text(response)), + ResultFormat::Json => render_json_success("inspect", response.state(), response), + } +} + +fn render_query_output( + response: &QueryResponse, + result_format: ResultFormat, +) -> Result { + match result_format { + ResultFormat::Text => Ok(render_query_text(response)), + ResultFormat::Json => render_json_success("query", response.state(), response), + } +} + +fn render_export_output( + response: &ExportResponse, + result_format: ResultFormat, +) -> Result { + match result_format { + ResultFormat::Text => Ok(format!( + "exported {} rows from {} to {}", + response.row_count, + render_target_summary(&response.target), + response.export.output_path + )), + ResultFormat::Json => render_json_success("export", response.state(), response), + } +} + +fn render_error_output(error: &AppError, result_format: ResultFormat) -> String { + match result_format { + ResultFormat::Text => error.to_string(), + ResultFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "error", + "operation": error.operation, + "state": OperationState::Error, + "error": error, + })) + .unwrap_or_else(|json_error| { + format!( + "{{\"status\":\"error\",\"operation\":\"{}\",\"error\":\"{}\"}}", + error.operation, json_error + ) + }), + } +} + +fn render_cli_error_output( + operation: &str, + result_format: ResultFormat, + message: impl Into, +) -> String { + let message = message.into(); + match result_format { + ResultFormat::Text => message, + ResultFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "error", + "operation": operation, + "state": OperationState::Error, + "error": { + "operation": operation, + "kind": "validation", + "message": message, + "target": serde_json::Value::Null, + }, + })) + .unwrap_or_else(|json_error| { + format!( + "{{\"status\":\"error\",\"operation\":\"{}\",\"error\":\"{}\"}}", + operation, json_error + ) + }), + } +} + +fn render_json_success( + operation: &str, + state: OperationState, + payload: &T, +) -> Result { + serde_json::to_string_pretty(&json!({ + "status": "success", + "operation": operation, + "state": state, + "data": payload, + })) + .map_err(|error| format!("failed to serialize {operation} result as json: {error}")) +} + +fn render_target_summary(target: &db_app::ConnectionSummary) -> String { + match &target.password_env_var { + Some(password_env_var) => { + format!("{} via password env {}", target.endpoint, password_env_var) + } + None => target.endpoint.clone(), + } +} + +fn render_inspect_text(response: &InspectResponse) -> String { + match &response.payload { + InspectPayload::Schemas(schemas) => { if schemas.is_empty() { - return format!("no schemas found for {}", profile.redacted_summary()); + return format!( + "no schemas found for {}", + render_target_summary(&response.target) + ); } - let names = schemas - .into_iter() - .map(|schema| schema.name) + let body = schemas + .iter() + .map(render_schema) .collect::>() .join("\n"); - format!("schemas for {}\n{names}", profile.redacted_summary()) + format!( + "schemas for {}\n{body}", + render_target_summary(&response.target) + ) } - InspectResult::Tables(tables) => { + InspectPayload::Tables(tables) => { if tables.is_empty() { - return format!("no tables found for {}", profile.redacted_summary()); + return format!( + "no tables found for {}", + render_target_summary(&response.target) + ); } let body = tables - .into_iter() + .iter() .map(render_table) .collect::>() .join("\n"); - format!("tables for {}\n{body}", profile.redacted_summary()) + format!( + "tables for {}\n{body}", + render_target_summary(&response.target) + ) } - InspectResult::Columns(columns) => { + InspectPayload::Columns(columns) => { if columns.is_empty() { - return format!("no columns found for {}", profile.redacted_summary()); + return format!( + "no columns found for {}", + render_target_summary(&response.target) + ); } let body = columns - .into_iter() + .iter() .map(render_column) .collect::>() .join("\n"); - format!("columns for {}\n{body}", profile.redacted_summary()) + format!( + "columns for {}\n{body}", + render_target_summary(&response.target) + ) } } } -fn render_query_result( - profile: &ConnectionProfile, - request: &QueryRequest, - result: QueryResult, -) -> String { - if let Some(rows_affected) = result.rows_affected { +fn render_query_text(response: &QueryResponse) -> String { + if let Some(rows_affected) = response.result.rows_affected { return format!( "query succeeded on {} from {} (rows affected: {})", - profile.redacted_summary(), - request.source.label(), + render_target_summary(&response.target), + response.source.label, rows_affected ); } @@ -533,150 +781,39 @@ fn render_query_result( let mut lines = Vec::new(); lines.push(format!( "query succeeded on {} from {}", - profile.redacted_summary(), - request.source.label() + render_target_summary(&response.target), + response.source.label )); - lines.push(result.columns.join("\t")); - if result.rows.is_empty() { + lines.push(response.result.columns.join("\t")); + if response.result.rows.is_empty() { lines.push(String::from("(0 rows)")); } else { - lines.extend(result.rows.into_iter().map(|row| row.join("\t"))); + lines.extend(response.result.rows.iter().map(|row| row.join("\t"))); } lines.join("\n") } -fn render_table(table: TableSummary) -> String { +fn render_schema(schema: &SchemaItem) -> String { + schema.name.clone() +} + +fn render_table(table: &TableItem) -> String { format!("{}\t{}\t{}", table.schema, table.name, table.kind) } -fn render_column(column: ColumnSummary) -> String { +fn render_column(column: &ColumnItem) -> String { format!( "{}\t{}\tnullable={}\tprimary_key={}", column.name, column.data_type, column.nullable, column.primary_key ) } -fn write_export(export: &ExportRequest, result: &QueryResult) -> Result<(), String> { - let body = render_export_body(export.format, result); - - fs::write(&export.output_path, body).map_err(|error| { - format!( - "failed to write export {}: {error}", - export.output_path.display() - ) - }) -} - -fn validate_export_path(export: &ExportRequest) -> Result<(), String> { - if export.output_path.exists() && !export.overwrite { - return Err(format!( - "export request is invalid: output file {} already exists; pass --overwrite to replace it", - export.output_path.display() - )); - } - - if export.output_path.is_dir() { - return Err(format!( - "export request is invalid: output path {} is a directory; provide a file path", - export.output_path.display() - )); - } - - if let Some(parent) = export.output_path.parent() { - if !parent.as_os_str().is_empty() && !parent.exists() { - return Err(format!( - "export request is invalid: parent directory {} does not exist; create it first or choose another output path", - parent.display() - )); - } - } - - Ok(()) -} - -fn validate_export_result(result: &QueryResult) -> Result<(), String> { - if result.rows_affected.is_some() { - return Err(String::from( - "export requires a row-returning query; the provided SQL completed without a result set", - )); - } - - Ok(()) -} - -fn render_export_body(format: ExportFormat, result: &QueryResult) -> String { - match format { - ExportFormat::Csv => render_csv(result), - ExportFormat::Json => render_json(result), - } -} - -fn render_csv(result: &QueryResult) -> String { - let mut lines = Vec::new(); - lines.push(result.columns.join(",")); - lines.extend(result.rows.iter().map(|row| { - row.iter() - .map(|value| escape_csv(value)) - .collect::>() - .join(",") - })); - - if lines.is_empty() { - String::new() - } else { - format!("{}\n", lines.join("\n")) - } -} - -fn escape_csv(value: &str) -> String { - if value.contains(',') || value.contains('"') || value.contains('\n') { - format!("\"{}\"", value.replace('"', "\"\"")) - } else { - value.to_string() - } -} - -fn render_json(result: &QueryResult) -> String { - let rows = result - .rows - .iter() - .map(|row| { - let fields = result - .columns - .iter() - .zip(row.iter()) - .map(|(column, value)| { - format!("\"{}\":\"{}\"", escape_json(column), escape_json(value)) - }) - .collect::>() - .join(","); - format!(" {{{fields}}}") - }) - .collect::>() - .join(",\n"); - - if rows.is_empty() { - String::from("[]\n") - } else { - format!("[\n{rows}\n]\n") - } -} - -fn escape_json(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") -} - -fn row_count(result: &QueryResult) -> usize { - result.rows.len() -} - #[cfg(test)] mod tests { use super::*; + use db_app::QueryDataset; use rusqlite::Connection as SqliteConnection; + use serde_json::Value; use std::env; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -711,6 +848,7 @@ mod tests { parsed.params, vec![String::from("alpha"), String::from("beta")] ); + assert_eq!(parsed.result_format, ResultFormat::Text); assert!(matches!(parsed.target.driver, Some(DriverArg::Postgres))); assert_eq!(parsed.target.port, Some(55432)); } @@ -756,6 +894,25 @@ mod tests { assert_eq!(parsed.schema.as_deref(), Some("qa_demo")); assert_eq!(parsed.table.as_deref(), Some("accounts")); + assert_eq!(parsed.result_format, ResultFormat::Text); + } + + #[test] + fn parse_query_args_accepts_json_result_format() { + let args = vec![ + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + String::from("demo.sqlite"), + String::from("--sql"), + String::from("select 1"), + String::from("--result-format"), + String::from("json"), + ]; + + let parsed = parse_query_args(&args).expect("query args should parse"); + + assert_eq!(parsed.result_format, ResultFormat::Json); } #[test] @@ -809,6 +966,169 @@ mod tests { fs::remove_file(path).expect("temporary sqlite database should be removed"); } + #[test] + fn sqlite_query_json_output_is_machine_readable() { + let path = temp_sqlite_db_path("dbtool-cli-json"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let output = run(vec![ + String::from("query"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--sql"), + String::from("SELECT COUNT(*) AS count FROM tickets WHERE status = ?1"), + String::from("--param"), + String::from("open"), + String::from("--result-format"), + String::from("json"), + ]) + .expect("sqlite json query should succeed"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "success"); + assert_eq!(json["operation"], "query"); + assert_eq!(json["state"], "success"); + assert_eq!(json["data"]["target"]["driver"], "sqlite"); + assert_eq!(json["data"]["source"]["kind"], "inline"); + assert_eq!(json["data"]["result"]["columns"][0], "count"); + assert_eq!(json["data"]["result"]["rows"][0][0], "3"); + + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn inspect_json_output_wraps_request_validation_error() { + let output = run(vec![ + String::from("inspect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + String::from("demo.sqlite"), + String::from("--table"), + String::from("accounts"), + String::from("--result-format"), + String::from("json"), + ]) + .expect_err("inspect should fail before driver call"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "error"); + assert_eq!(json["operation"], "inspect"); + assert_eq!(json["state"], "error"); + assert_eq!(json["error"]["operation"], "inspect"); + assert_eq!(json["error"]["kind"], "validation"); + assert_eq!( + json["error"]["message"], + "inspect request is invalid: table inspection requires --schema" + ); + assert!(json["error"]["target"].is_null()); + } + + #[test] + fn query_json_output_wraps_empty_sql_validation_error() { + let output = run(vec![ + String::from("query"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + String::from("demo.sqlite"), + String::from("--sql"), + String::from(""), + String::from("--result-format"), + String::from("json"), + ]) + .expect_err("query should fail before driver call"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "error"); + assert_eq!(json["operation"], "query"); + assert_eq!(json["state"], "error"); + assert_eq!(json["error"]["operation"], "query"); + assert_eq!(json["error"]["kind"], "validation"); + assert_eq!(json["error"]["message"], "query text is empty"); + assert!(json["error"]["target"].is_null()); + } + + #[test] + fn connect_json_output_wraps_missing_password_env_error() { + let output = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("postgres"), + String::from("--host"), + String::from("127.0.0.1"), + String::from("--port"), + String::from("5432"), + String::from("--database"), + String::from("demo"), + String::from("--username"), + String::from("demo"), + String::from("--password-env"), + String::from("DBTOOL_CLI_TEST_MISSING_ENV"), + String::from("--result-format"), + String::from("json"), + ]) + .expect_err("connect should fail before driver call"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "error"); + assert_eq!(json["operation"], "connect"); + assert_eq!(json["state"], "error"); + assert_eq!(json["error"]["operation"], "connect"); + assert_eq!(json["error"]["kind"], "validation"); + assert_eq!( + json["error"]["message"], + "missing password env `DBTOOL_CLI_TEST_MISSING_ENV`" + ); + assert!(json["error"]["target"].is_null()); + } + + #[test] + fn query_json_output_wraps_sql_file_read_error() { + let missing_file = env::temp_dir().join(format!( + "dbtool-missing-query-{}.sql", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + + let output = run(vec![ + String::from("query"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + String::from("demo.sqlite"), + String::from("--file"), + missing_file.display().to_string(), + String::from("--result-format"), + String::from("json"), + ]) + .expect_err("query should fail when sql file is missing"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "error"); + assert_eq!(json["operation"], "query"); + assert_eq!(json["state"], "error"); + assert_eq!(json["error"]["operation"], "query"); + assert_eq!(json["error"]["kind"], "validation"); + assert!(json["error"]["message"] + .as_str() + .expect("error message should be a string") + .contains(&format!( + "failed to read SQL file {}", + missing_file.display() + ))); + assert!(json["error"]["target"].is_null()); + } + #[test] fn sqlite_connect_rejects_non_database_file() { let unique = SystemTime::now() @@ -902,15 +1222,17 @@ mod tests { #[test] fn export_csv_escapes_commas_quotes_and_newlines() { - let body = render_export_body( + let body = db_app::render_export_body( ExportFormat::Csv, - &QueryResult::rows( - vec![String::from("title"), String::from("notes")], - vec![vec![ + &QueryDataset { + columns: vec![String::from("title"), String::from("notes")], + rows: vec![vec![ String::from("emoji smoke 😀"), String::from("line one,\n\"line two\""), ]], - ), + rows_affected: None, + row_count: 1, + }, ); assert_eq!( @@ -921,15 +1243,17 @@ mod tests { #[test] fn export_json_escapes_quotes_and_newlines() { - let body = render_export_body( + let body = db_app::render_export_body( ExportFormat::Json, - &QueryResult::rows( - vec![String::from("title"), String::from("notes")], - vec![vec![ + &QueryDataset { + columns: vec![String::from("title"), String::from("notes")], + rows: vec![vec![ String::from("csv export mismatch"), String::from("line one\n\"line two\""), ]], - ), + rows_affected: None, + row_count: 1, + }, ); assert_eq!( @@ -945,7 +1269,7 @@ mod tests { let export = ExportRequest::new(ExportFormat::Csv, output.clone(), false).expect("export valid"); - let error = validate_export_path(&export).expect_err("missing parent should fail"); + let error = db_app::validate_export_path(&export).expect_err("missing parent should fail"); assert_eq!( error, @@ -961,8 +1285,13 @@ mod tests { #[test] fn export_rejects_non_row_returning_results() { - let error = validate_export_result(&QueryResult::command(2)) - .expect_err("command result should not be exportable"); + let error = db_app::validate_export_result(&QueryDataset { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(2), + row_count: 0, + }) + .expect_err("command result should not be exportable"); assert_eq!( error, @@ -1059,6 +1388,99 @@ mod tests { fs::remove_file(path).expect("temporary sqlite database should be removed"); } + #[test] + fn sqlite_export_json_result_format_returns_metadata() { + let path = temp_sqlite_db_path("dbtool-cli-export-json-result"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let output_dir = path + .parent() + .expect("temp sqlite db should have parent") + .join(format!( + "dbtool-export-json-result-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + fs::create_dir_all(&output_dir).expect("output dir should be created"); + let json_path = output_dir.join("tickets.json"); + let export_sql = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/sql/sqlite/export_query.sql"); + + let output = run(vec![ + String::from("export"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--file"), + export_sql.display().to_string(), + String::from("--format"), + String::from("json"), + String::from("--output"), + json_path.display().to_string(), + String::from("--result-format"), + String::from("json"), + ]) + .expect("json result export should succeed"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "success"); + assert_eq!(json["operation"], "export"); + assert_eq!(json["state"], "success"); + assert_eq!(json["data"]["export"]["format"], "json"); + assert_eq!(json["data"]["row_count"], 4); + + fs::remove_file(&json_path).expect("json export should be removed"); + fs::remove_dir(&output_dir).expect("output dir should be removed"); + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn sqlite_query_json_output_exposes_empty_state() { + let path = temp_sqlite_db_path("dbtool-cli-empty-json"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let output = run(vec![ + String::from("query"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--sql"), + String::from("SELECT id FROM tickets WHERE status = ?1"), + String::from("--param"), + String::from("missing"), + String::from("--result-format"), + String::from("json"), + ]) + .expect("sqlite json query should succeed with empty rows"); + + let json: Value = serde_json::from_str(&output).expect("output should be valid json"); + assert_eq!(json["status"], "success"); + assert_eq!(json["operation"], "query"); + assert_eq!(json["state"], "empty"); + assert_eq!(json["data"]["result"]["columns"][0], "id"); + assert_eq!(json["data"]["result"]["row_count"], 0); + assert!(json["data"]["result"]["rows"] + .as_array() + .expect("rows should be an array") + .is_empty()); + + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + fn temp_sqlite_db_path(prefix: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/apps/tui/README.md b/apps/tui/README.md index 8f0980d..517d2c9 100644 --- a/apps/tui/README.md +++ b/apps/tui/README.md @@ -13,6 +13,7 @@ - 键盘驱动的连接切换工作流,以及连接 `loading / success / failure` 可视状态 - `sqlite-local` 的真实 connect / inspect 路径,schema browser 可展示 live schema / table / column - `sqlite-local` 的真实 query / results / export 工作流,直接复用 `crates/db-app` +- PostgreSQL / MySQL 的 Docker demo live activation / query / export 路径 - 查询草稿切换、基础键入编辑、执行触发与执行状态反馈 - 成功 / 空结果 / 错误三类查询结果展示,以及宽表列分页提示 - 当前结果集导出到 `/tmp` 的 CSV / JSON 反馈 @@ -23,11 +24,25 @@ 当前不包含: -- 真实网络数据库连接激活 - 多连接并发会话 - 自定义新增 / 编辑连接表单 +- 非 demo 自定义 network profile - 完整 SQL 编辑器能力 +## usable-v1 口径边界 + +当前 TUI 能力要按三层结论理解,避免把局部 live path 误记为整体通过: + +| 层级 | 当前结论 | 含义 | +| --- | --- | --- | +| shell baseline | `pass` | 六区布局、焦点、状态、TTY 启动与退出可直接复核 | +| `sqlite-local` local live | `partial` | 单机会话内可复核 connect / inspect / query / export,但只覆盖本地 SQLite | +| PostgreSQL / MySQL network live | `pass` | 当前 runner 可通过 `host.docker.internal`、Docker demo 容器与 TTY smoke 脚本复核真实激活、查询、导出 | + +- `sqlite-local` 仍是最轻量的本地单机验证入口,但不再是唯一 live 路径。 +- 当前 usable-v1 的 runner 内 network live 回放口径已经建立,可直接复核 PostgreSQL / MySQL。 +- QA 在对外汇报时,应明确写成“shell baseline = pass、`sqlite-local` = partial、PostgreSQL/MySQL network live = pass(demo path)”。 + ## 运行方式 在项目根目录执行: @@ -38,6 +53,7 @@ cargo run -p dbtool-tui 当前默认 live 路径是 `sqlite-local`,底层使用 `examples/tmp/dbtool-demo.sqlite`。 启动后会自动对 `sqlite-local` 执行 connect + inspect;切换连接时会重新触发 live activation。 +内置 `reporting-postgres` / `orders-mysql` profile 默认走 `host.docker.internal:55432/53306`,并分别复用 `dbtool_demo` / `qa_demo` demo 数据。 如果当前 runner 缺少 Rust 工具链,可先使用已有产物: @@ -45,6 +61,38 @@ cargo run -p dbtool-tui ./target/debug/dbtool-tui ``` +## TTY smoke 契约 + +- `dbtool-tui` 需要交互式 TTY;正常终端启动与自动化 smoke 都必须走 TTY 路径。 +- 当前最小可重复入口: + +```bash +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui +``` + +- PostgreSQL live smoke: + +```bash +export DBTOOL_PASSWORD=dbtool +scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui +``` + +- MySQL live smoke: + +```bash +export DBTOOL_PASSWORD=dbtool +scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui +``` + +- 小终端降级入口: + +```bash +scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.log +``` + +- `./target/debug/dbtool-tui --help` 不是有效 smoke;当前它只用于确认“非 TTY 会被明确拒绝”这一限制。 +- 共享 runbook 见 `TUI_SMOKE_RUNBOOK.md`。 + ## 键盘交互 - `Tab` / `Shift+Tab`:切换焦点面板 @@ -59,6 +107,14 @@ cargo run -p dbtool-tui - `Esc`:回到默认工作台 - `q`:退出 +## 状态恢复与键位一致性 + +- `Esc` 在两个场景下都承担“回到稳定态”的职责:编辑模式下先退回 navigate mode;普通导航下回到默认 `Workspace` + `Query Editor` 焦点。 +- `r` 是唯一的显式恢复键:会同时清空 query 结果、query 错误、export 成功/失败反馈,并把界面状态收口回 `Ready`。 +- `[` / `]` 只在两个焦点区生效:`Query Editor` 下切换草稿,`Results` 下横向翻页;其他面板按下不会触发隐藏状态变化。 +- `x` / `j` 只在当前工作区已有“成功且可导出”的结果集时成立;若查询仍在运行或当前没有可导出结果,会保留明确错误/提示,而不是静默失败。 +- `Enter` 的语义由当前焦点决定:`Connections` = 激活连接,`Schema Browser` = 展开/确认对象,`Query Editor` = 执行查询;插入模式下 `Enter` 只插入换行。 + ## 快速测试路径 如果你只是想快速走通一遍当前可见工作流,推荐按下面步骤操作: @@ -103,12 +159,14 @@ QA 可按以下步骤复核: ## 当前限制 -- `sqlite-local` 是当前唯一可在本地 runner 直接完成 live connect / inspect / query / export 的路径 -- Postgres / MySQL 连接仍以可读失败态保留,用于验证多目标上下文和错误持续可见性 +- `sqlite-local` 仍是当前最快的本地 runner live connect / inspect / query / export 路径 +- Postgres / MySQL 当前默认指向 Docker demo 容器;若 demo stack 未启动,会回退为可读失败态 - 当前 runner 若缺少 `cargo`,需要使用已有二进制做交互 smoke;源码重建与测试需在具备 Rust 工具链的环境完成 +- 非 TTY 启动当前会明确报错并退出;这是已记录限制,不是可支持的帮助命令路径 共享 QA 文档: - `TUI_ACCEPTANCE_CHECKLIST.md` +- `TUI_SMOKE_RUNBOOK.md` - `TUI_TEST_STRATEGY.md` - `TUI_REGRESSION_CHECKLIST.md` diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs index b501b10..c85f391 100644 --- a/apps/tui/src/main.rs +++ b/apps/tui/src/main.rs @@ -1,5 +1,5 @@ use std::env; -use std::io::{self, stdout}; +use std::io::{self, stdin, stdout, IsTerminal}; use std::path::PathBuf; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; @@ -29,12 +29,27 @@ use ratatui::{ }; fn main() -> io::Result<()> { + if let Some(message) = tty_requirement_message(stdin().is_terminal(), stdout().is_terminal()) { + eprintln!("{message}"); + std::process::exit(1); + } + let mut terminal = setup_terminal()?; let run_result = run_app(&mut terminal); restore_terminal(&mut terminal)?; run_result } +fn tty_requirement_message(stdin_is_tty: bool, stdout_is_tty: bool) -> Option { + if stdin_is_tty && stdout_is_tty { + return None; + } + + Some(format!( + "dbtool-tui requires an interactive TTY on stdin and stdout.\nDetected stdin TTY: {stdin_is_tty}\nDetected stdout TTY: {stdout_is_tty}\nUse `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` for repeatable smoke validation.\n`--help` is not a supported TUI smoke path." + )) +} + fn setup_terminal() -> io::Result>> { enable_raw_mode()?; let mut output = stdout(); @@ -43,9 +58,7 @@ fn setup_terminal() -> io::Result>> { Terminal::new(backend) } -fn restore_terminal( - terminal: &mut Terminal>, -) -> io::Result<()> { +fn restore_terminal(terminal: &mut Terminal>) -> io::Result<()> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor() @@ -291,7 +304,10 @@ struct BrowserSchema { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum BrowserRow { Schema(usize), - Object { schema_index: usize, object_index: usize }, + Object { + schema_index: usize, + object_index: usize, + }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -406,6 +422,7 @@ struct ConnectionRecord { environment_label: &'static str, database_label: &'static str, mode_label: &'static str, + query_qualifier: &'static str, validation_label: String, status: ConnectionHealth, browser_schemas: &'static [BrowserSchema], @@ -434,16 +451,12 @@ impl ConnectionRecord { rows.push(BrowserRow::Schema(schema_index)); if self.expanded_schemas[schema_index] && schema.state == SchemaState::Ready { - rows.extend( - schema - .objects - .iter() - .enumerate() - .map(|(object_index, _)| BrowserRow::Object { - schema_index, - object_index, - }), - ); + rows.extend(schema.objects.iter().enumerate().map(|(object_index, _)| { + BrowserRow::Object { + schema_index, + object_index, + } + })); } } @@ -480,8 +493,11 @@ impl ConnectionRecord { } fn move_browser_selection(&mut self, delta: isize) -> String { - self.selected_browser_row = - cycle_index(self.selected_browser_row, self.visible_browser_rows().len(), delta); + self.selected_browser_row = cycle_index( + self.selected_browser_row, + self.visible_browser_rows().len(), + delta, + ); match self.selected_browser_row() { BrowserRow::Schema(index) => { @@ -619,6 +635,7 @@ impl Default for App { environment_label: "Local file", database_label: "main", mode_label: "Evidence review", + query_qualifier: "main", validation_label: String::from("Live local demo"), status: ConnectionHealth::Ready, browser_schemas: &SQLITE_SCHEMAS, @@ -634,75 +651,44 @@ impl Default for App { ConnectionRecord { profile: postgres_profile.clone(), summary: ConnectionSummary::from(&postgres_profile), - environment_label: "Production", - database_label: "analytics", - mode_label: "Read-only audit", - validation_label: String::from("Unavailable in local runner"), - status: ConnectionHealth::Failed, + environment_label: "Docker demo", + database_label: "dbtool_demo", + mode_label: "Network live", + query_qualifier: "qa_demo", + validation_label: String::from("Ready for activation"), + status: ConnectionHealth::Ready, browser_schemas: &POSTGRES_SCHEMAS, expanded_schemas: vec![true, false, false, false], selected_browser_row: 1, details: &[ - "Production profile stays visible for layout review.", - "This runner has no live network credentials.", - "Use sqlite-local for end-to-end verification.", + "Docker-backed PostgreSQL demo uses host.docker.internal:55432.", + "Activate this target to load live schema, query, and export state.", + "Query drafts switch to qa_demo-qualified SQL for this connection.", ], - failure_message: Some(String::from( - "Live Postgres access is not available in this local runner. Switch to sqlite-local for a working end-to-end path.", - )), + failure_message: None, }, ConnectionRecord { profile: mysql_profile.clone(), summary: ConnectionSummary::from(&mysql_profile), - environment_label: "Staging", + environment_label: "Docker demo", database_label: "qa_demo", - mode_label: "Write enabled", - validation_label: String::from("Unavailable in local runner"), - status: ConnectionHealth::Failed, + mode_label: "Network live", + query_qualifier: "qa_demo", + validation_label: String::from("Ready for activation"), + status: ConnectionHealth::Ready, browser_schemas: &MYSQL_SCHEMAS, expanded_schemas: vec![true, false], selected_browser_row: 1, details: &[ - "Staging profile stays visible for multi-target review.", - "Network targets stay in a readable failed state here.", - "Use sqlite-local for the live demo path.", + "Docker-backed MySQL demo uses host.docker.internal:53306.", + "Activate this target to load live schema, query, and export state.", + "Query drafts switch to qa_demo-qualified SQL for this connection.", ], - failure_message: Some(String::from( - "Live MySQL access is not available in this local runner. Use sqlite-local for end-to-end query and export verification.", - )), + failure_message: None, }, ]; - let query_drafts = vec![ - QueryDraft { - name: "account_ticket_summary.sql", - sql: String::from( - "select\n a.id as account_id,\n a.display_name,\n count(t.id) as ticket_count,\n sum(case when t.status = 'open' then 1 else 0 end) as open_ticket_count\nfrom accounts a\nleft join tickets t on t.account_id = a.id\ngroup by a.id, a.display_name\norder by a.id;", - ), - detail: "Success state keeps execution detail adjacent to the real result table.", - }, - QueryDraft { - name: "ticket_export_preview.sql", - sql: String::from( - "select\n t.id as ticket_id,\n a.email,\n t.title,\n t.status,\n t.amount_cents,\n t.created_at\nfrom tickets t\njoin accounts a on a.id = t.account_id\norder by t.id;", - ), - detail: "Use [ and ] in Results focus to page across wide real result columns.", - }, - QueryDraft { - name: "empty_recent_tickets.sql", - sql: String::from( - "select\n id,\n status,\n created_at\nfrom tickets\nwhere created_at > '2026-04-01T00:00:00Z'\n and status = 'open'\norder by created_at desc;", - ), - detail: "Empty results stay distinct from query failures and remain exportable.", - }, - QueryDraft { - name: "bad_syntax.sql", - sql: String::from( - "select id, status\nform tickets\nwhere status = 'open';", - ), - detail: "Error summary stays visible in the workspace with the offending query buffer.", - }, - ]; + let query_drafts = build_query_drafts("main"); Self { should_quit: false, @@ -803,7 +789,8 @@ impl App { KeyCode::Char('j') => self.start_export(ExportFormat::Json), KeyCode::Char('i') if self.focus == FocusArea::Editor => { self.editor_mode = EditorMode::Insert; - self.activity_message = String::from("Editor insert mode enabled. Esc exits insert mode."); + self.activity_message = + String::from("Editor insert mode enabled. Esc exits insert mode."); } KeyCode::Char('[') => self.handle_bracket_key(-1), KeyCode::Char(']') => self.handle_bracket_key(1), @@ -811,7 +798,9 @@ impl App { if self.focus == FocusArea::Connections { self.activate_selected_connection(); } else if self.focus == FocusArea::Schema { - let message = self.active_connection_record_mut().activate_browser_selection(); + let message = self + .active_connection_record_mut() + .activate_browser_selection(); self.activity_message = message; self.update_surface_state(); } else if self.focus == FocusArea::Editor { @@ -836,9 +825,12 @@ impl App { } KeyCode::Enter => { self.current_query_draft_mut().sql.push('\n'); - self.activity_message = String::from("Inserted a newline. Press Esc, then Enter to run."); + self.activity_message = + String::from("Inserted a newline. Press Esc, then Enter to run."); } - KeyCode::Char(ch) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { + KeyCode::Char(ch) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { self.current_query_draft_mut().sql.push(ch); } _ => {} @@ -884,7 +876,8 @@ impl App { fn handle_bracket_key(&mut self, delta: isize) { match self.focus { FocusArea::Editor => { - self.selected_query = cycle_index(self.selected_query, self.query_drafts.len(), delta); + self.selected_query = + cycle_index(self.selected_query, self.query_drafts.len(), delta); self.clear_result_context(); self.update_surface_state(); self.activity_message = format!( @@ -925,12 +918,19 @@ impl App { FocusArea::Connections => { self.selected_connection = cycle_index(self.selected_connection, self.connections.len(), delta); - let selected_name = self.selected_connection_record().summary.profile_name.clone(); - self.activity_message = - format!("Selected connection moved to {selected_name}. Press Enter to activate it."); + let selected_name = self + .selected_connection_record() + .summary + .profile_name + .clone(); + self.activity_message = format!( + "Selected connection moved to {selected_name}. Press Enter to activate it." + ); } FocusArea::Schema => { - let message = self.active_connection_record_mut().move_browser_selection(delta); + let message = self + .active_connection_record_mut() + .move_browser_selection(delta); self.activity_message = message; self.update_surface_state(); } @@ -938,7 +938,8 @@ impl App { let row_count = self.current_result_rows().len(); if row_count == 0 { - self.activity_message = String::from("Current result set has no rows to navigate."); + self.activity_message = + String::from("Current result set has no rows to navigate."); return; } @@ -1182,6 +1183,8 @@ impl App { self.selected_connection = connection_index; self.view = WorkspaceView::Workspace; self.focus = FocusArea::Editor; + self.query_drafts = build_query_drafts(self.connections[connection_index].query_qualifier); + self.selected_query = self.selected_query.min(self.query_drafts.len().saturating_sub(1)); self.clear_result_context(); let run_id = self.next_run_id; @@ -1232,9 +1235,9 @@ impl App { let draft = self.current_query_draft(); match self.query_run_state { - QueryRunState::Idle => String::from( - "Enter runs the draft. Press i to edit. After success, x/j exports.", - ), + QueryRunState::Idle => { + String::from("Enter runs the draft. Press i to edit. After success, x/j exports.") + } QueryRunState::Running => format!( "Executing {} against {}…", draft.name, @@ -1274,12 +1277,8 @@ impl App { fn export_execution_copy(&self) -> String { match self.export_run_state { - ExportRunState::Idle => { - String::from("Run a row-returning query before exporting.") - } - ExportRunState::Running => String::from( - "Export is running. Results stay visible.", - ), + ExportRunState::Idle => String::from("Run a row-returning query before exporting."), + ExportRunState::Running => String::from("Export is running. Results stay visible."), ExportRunState::Success => self .last_export_response .as_ref() @@ -1361,7 +1360,8 @@ impl App { }; self.last_query_response = Some(response); self.last_query_error = None; - self.activity_message = String::from("Query execution finished and workspace state updated."); + self.activity_message = + String::from("Query execution finished and workspace state updated."); } Err(error) => { self.query_run_state = QueryRunState::Error; @@ -1396,7 +1396,11 @@ impl App { record.failure_message = None; record.browser_schemas = load_result.browser_schemas; record.expanded_schemas = vec![true; record.browser_schemas.len()]; - record.selected_browser_row = if record.browser_schemas.is_empty() { 0 } else { 0 }; + record.selected_browser_row = if record.browser_schemas.is_empty() { + 0 + } else { + 0 + }; self.activity_message = format!( "Active connection {} loaded with {} schema entries.", record.summary.profile_name, @@ -1424,11 +1428,7 @@ impl App { self.update_surface_state(); } - fn apply_export_result( - &mut self, - result: Result, - duration_ms: u128, - ) { + fn apply_export_result(&mut self, result: Result, duration_ms: u128) { self.pending_export_run_id = None; self.last_export_duration_ms = Some(duration_ms); @@ -1453,7 +1453,12 @@ impl App { fn default_export_request(&self, format: ExportFormat) -> ExportRequest { let filename = format!( "dbtool-tui-{}-{}.{}", - sanitize_filename(self.active_connection_record().summary.profile_name.as_str()), + sanitize_filename( + self.active_connection_record() + .summary + .profile_name + .as_str() + ), sanitize_filename(self.current_query_draft().name), format.file_extension() ); @@ -1810,7 +1815,9 @@ fn load_connection_context(profile: &ConnectionProfile) -> Result Result<&'static [BrowserSchema], AppError> { +fn build_browser_schemas( + profile: &ConnectionProfile, +) -> Result<&'static [BrowserSchema], AppError> { let root_request = InspectRequest { schema: None, table: None, @@ -1887,10 +1894,7 @@ fn build_browser_schemas(profile: &ConnectionProfile) -> Result<&'static [Browse name: leak_str(table.name.clone()), kind: object_kind_from_str(table.kind.as_str()), columns: leak_columns(Vec::new()), - note: leak_str(format!( - "Column inspect failed: {}", - error.message - )), + note: leak_str(format!("Column inspect failed: {}", error.message)), }); continue; } @@ -2018,10 +2022,10 @@ fn postgres_profile() -> ConnectionProfile { ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { - host: String::from("reporting.internal"), - port: 55432, + host: tui_demo_host("DBTOOL_TUI_POSTGRES_HOST"), + port: tui_demo_port("DBTOOL_TUI_POSTGRES_PORT", 55432), }, - Some(String::from("analytics")), + Some(String::from("dbtool_demo")), Some(String::from("dbtool")), ) .expect("postgres target should be valid"), @@ -2036,18 +2040,67 @@ fn mysql_profile() -> ConnectionProfile { ConnectionTarget::new( DatabaseKind::Mysql, ConnectionTransport::Tcp { - host: String::from("orders.internal"), - port: 53306, + host: tui_demo_host("DBTOOL_TUI_MYSQL_HOST"), + port: tui_demo_port("DBTOOL_TUI_MYSQL_PORT", 53306), }, Some(String::from("qa_demo")), - Some(String::from("qa_readonly")), + Some(String::from("dbtool")), ) .expect("mysql target should be valid"), - Some(String::from("ORDERS_DB_PASSWORD")), + Some(String::from("DBTOOL_PASSWORD")), ) .expect("mysql profile should be valid") } +fn build_query_drafts(schema_qualifier: &str) -> Vec { + let accounts_table = format!("{schema_qualifier}.accounts"); + let tickets_table = format!("{schema_qualifier}.tickets"); + + vec![ + QueryDraft { + name: "account_ticket_summary.sql", + sql: format!( + "select\n a.id as account_id,\n a.display_name,\n count(t.id) as ticket_count,\n sum(case when t.status = 'open' then 1 else 0 end) as open_ticket_count\nfrom {accounts_table} a\nleft join {tickets_table} t on t.account_id = a.id\ngroup by a.id, a.display_name\norder by a.id;" + ), + detail: "Success state keeps execution detail adjacent to the real result table.", + }, + QueryDraft { + name: "ticket_export_preview.sql", + sql: format!( + "select\n t.id as ticket_id,\n a.email,\n t.title,\n t.status,\n t.amount_cents,\n t.created_at\nfrom {tickets_table} t\njoin {accounts_table} a on a.id = t.account_id\norder by t.id;" + ), + detail: "Use [ and ] in Results focus to page across wide real result columns.", + }, + QueryDraft { + name: "empty_recent_tickets.sql", + sql: format!( + "select\n id,\n status,\n created_at\nfrom {tickets_table}\nwhere created_at > '2026-04-01T00:00:00Z'\n and status = 'open'\norder by created_at desc;" + ), + detail: "Empty results stay distinct from query failures and remain exportable.", + }, + QueryDraft { + name: "bad_syntax.sql", + sql: format!("select id, status\nform {tickets_table}\nwhere status = 'open';"), + detail: "Error summary stays visible in the workspace with the offending query buffer.", + }, + ] +} + +fn tui_demo_host(env_name: &str) -> String { + env::var(env_name) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| String::from("host.docker.internal")) +} + +fn tui_demo_port(env_name: &str, default_port: u16) -> u16 { + env::var(env_name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|port| *port != 0) + .unwrap_or(default_port) +} + fn sanitize_filename(value: &str) -> String { value .chars() @@ -2258,50 +2311,54 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) { } fn render_connections(frame: &mut Frame, area: Rect, app: &App) { - let items = app.connections.iter().enumerate().map(|(index, connection)| { - let marker = if index == app.active_connection { - "Current" - } else { - "Available" - }; + let items = app + .connections + .iter() + .enumerate() + .map(|(index, connection)| { + let marker = if index == app.active_connection { + "Current" + } else { + "Available" + }; - let style = if index == app.selected_connection { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else if connection.status == ConnectionHealth::Failed { - Style::default().fg(Color::Red) - } else if index == app.active_connection { - Style::default() - .fg(connection.status.accent()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Gray) - }; + let style = if index == app.selected_connection { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if connection.status == ConnectionHealth::Failed { + Style::default().fg(Color::Red) + } else if index == app.active_connection { + Style::default() + .fg(connection.status.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; - let detail_style = if index == app.selected_connection { - Style::default().fg(Color::Black) - } else { - Style::default().fg(Color::DarkGray) - }; + let detail_style = if index == app.selected_connection { + Style::default().fg(Color::Black) + } else { + Style::default().fg(Color::DarkGray) + }; - ListItem::new(Text::from(vec![ - Line::from(vec![ - Span::styled(marker, style), - Span::raw(" · "), - Span::styled(connection.summary.profile_name.clone(), style), - Span::raw(" · "), - Span::styled(connection.status.label(), style), - ]), - Line::from(Span::styled(connection.subtitle(), detail_style)), - Line::from(Span::styled(connection.endpoint_summary(), detail_style)), - Line::from(Span::styled( - format!("Validation: {}", connection.validation_label), - detail_style, - )), - ])) - }); + ListItem::new(Text::from(vec![ + Line::from(vec![ + Span::styled(marker, style), + Span::raw(" · "), + Span::styled(connection.summary.profile_name.clone(), style), + Span::raw(" · "), + Span::styled(connection.status.label(), style), + ]), + Line::from(Span::styled(connection.subtitle(), detail_style)), + Line::from(Span::styled(connection.endpoint_summary(), detail_style)), + Line::from(Span::styled( + format!("Validation: {}", connection.validation_label), + detail_style, + )), + ])) + }); let widget = List::new(items.collect::>()).block(panel_block( "Connections", @@ -2477,10 +2534,16 @@ fn render_editor(frame: &mut Frame, area: Rect, app: &App) { selected.summary.profile_name ))); lines.push(Line::from(format!("Driver: {}", selected.summary.driver))); - lines.push(Line::from(format!("Endpoint: {}", selected.summary.endpoint))); + lines.push(Line::from(format!( + "Endpoint: {}", + selected.summary.endpoint + ))); lines.push(Line::from(format!("Database: {}", selected.database_label))); lines.push(Line::from(format!("Mode: {}", selected.mode_label))); - lines.push(Line::from(format!("Validation: {}", selected.validation_label))); + lines.push(Line::from(format!( + "Validation: {}", + selected.validation_label + ))); } WorkspaceView::Help => { lines.push(Line::from("Query Workflow")); @@ -2520,10 +2583,7 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) { } WorkspaceView::Connections => { let widget = Paragraph::new(Text::from(vec![ - Line::from(format!( - "Current target: {}", - active.summary.profile_name - )), + Line::from(format!("Current target: {}", active.summary.profile_name)), Line::from(""), Line::from("Execution workflow"), Line::from("- Enter runs the current query"), @@ -2577,7 +2637,11 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) { frame.render_widget(table, area); } else { let widget = Paragraph::new(Text::from(vec![ - Line::from(format!("Schema: {} ({})", schema.name, schema.state.label())), + Line::from(format!( + "Schema: {} ({})", + schema.name, + schema.state.label() + )), Line::from(""), Line::from(schema.message), Line::from(""), @@ -2684,19 +2748,17 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) { .map(|_| Constraint::Length(16)) .collect::>(); - let table = ratatui::widgets::Table::new( - visible_rows.collect::>(), - constraints, - ) - .header( - Row::new(header).style( - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ) - .block(panel_block("Results", app.focus == FocusArea::Results)) - .column_spacing(1); + let table = + ratatui::widgets::Table::new(visible_rows.collect::>(), constraints) + .header( + Row::new(header).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .column_spacing(1); frame.render_widget(table, area); } @@ -2859,7 +2921,11 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) { Line::from(format!("Draft: {}", draft.name)), Line::from(app.query_execution_copy()), Line::from(""), - Line::from(format!("Browser target: {} ({})", schema.name, schema.state.label())), + Line::from(format!( + "Browser target: {} ({})", + schema.name, + schema.state.label() + )), Line::from(active.browser_status_message()), ]; @@ -2902,7 +2968,10 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) { } let widget = Paragraph::new(Text::from(lines)) - .block(panel_block("Status & Activity", app.focus == FocusArea::Status)) + .block(panel_block( + "Status & Activity", + app.focus == FocusArea::Status, + )) .wrap(Wrap { trim: true }); frame.render_widget(widget, area); @@ -3050,4 +3119,28 @@ mod tests { assert!(app.current_query_draft().sql.ends_with('x')); assert_eq!(app.editor_mode, EditorMode::Insert); } + + #[test] + fn tty_requirement_message_allows_real_terminal_pair() { + assert_eq!(tty_requirement_message(true, true), None); + } + + #[test] + fn tty_requirement_message_explains_non_tty_contract() { + let message = + tty_requirement_message(false, true).expect("non-tty startup should be rejected"); + + assert!(message.contains("requires an interactive TTY")); + assert!(message.contains("Detected stdin TTY: false")); + assert!(message.contains("scripts/tui/smoke-tty.sh")); + } + + #[test] + fn build_query_drafts_uses_connection_qualifier() { + let drafts = build_query_drafts("qa_demo"); + + assert!(drafts[0].sql.contains("from qa_demo.accounts a")); + assert!(drafts[0].sql.contains("left join qa_demo.tickets t")); + assert!(drafts[1].sql.contains("from qa_demo.tickets t")); + } } diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs index 1a1696d..b52eb1d 100644 --- a/crates/db-app/src/lib.rs +++ b/crates/db-app/src/lib.rs @@ -420,7 +420,10 @@ pub fn inspect( }) } -pub fn query(profile: &ConnectionProfile, request: &QueryRequest) -> Result { +pub fn query( + profile: &ConnectionProfile, + request: &QueryRequest, +) -> Result { validate_profile("query", profile)?; request .validate() diff --git a/crates/db-drivers/src/lib.rs b/crates/db-drivers/src/lib.rs index b2d85eb..2016bb8 100644 --- a/crates/db-drivers/src/lib.rs +++ b/crates/db-drivers/src/lib.rs @@ -15,6 +15,7 @@ use std::path::Path; use std::{error::Error, fmt}; const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; +const MYSQL_UTF8MB4_SESSION_INIT: &str = "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"; pub trait DatabaseDriver { fn kind(&self) -> DatabaseKind; @@ -430,17 +431,38 @@ fn connect_mysql(target: &ConnectionTarget) -> Result { .as_deref() .ok_or_else(|| DriverError::Connection("mysql requires a database".to_string()))?; - let builder = MySqlOptsBuilder::new() - .ip_or_hostname(Some(host)) - .tcp_port(port) - .user(target.username.as_deref()) - .pass(target.password.as_deref()) - .db_name(Some(database)) - .prefer_socket(false); + let builder = mysql_opts_builder( + host, + port, + target.username.as_deref(), + target.password.as_deref(), + database, + ); MySqlConn::new(builder).map_err(map_mysql_error) } +fn mysql_opts_builder( + host: &str, + port: u16, + username: Option<&str>, + password: Option<&str>, + database: &str, +) -> MySqlOptsBuilder { + MySqlOptsBuilder::new() + .ip_or_hostname(Some(host)) + .tcp_port(port) + .user(username) + .pass(password) + .db_name(Some(database)) + .prefer_socket(false) + .init(mysql_session_init_commands()) +} + +fn mysql_session_init_commands() -> Vec<&'static str> { + vec![MYSQL_UTF8MB4_SESSION_INIT] +} + fn connect_sqlite(target: &ConnectionTarget) -> Result { let path = match &target.transport { ConnectionTransport::File { path } => path, @@ -636,7 +658,7 @@ fn consume_mysql_query_result( let mut last_rows = Vec::new(); let mut last_rows_affected = 0; - while let Some(result_set) = result.iter() { + while let Some(mut result_set) = result.iter() { let columns = result_set .columns() .as_ref() @@ -645,18 +667,17 @@ fn consume_mysql_query_result( .collect::>(); let rows_affected = result_set.affected_rows(); - if columns.is_empty() { - last_rows_affected = rows_affected; - continue; - } - let mut rows = Vec::new(); - for row in result_set { + while let Some(row) = result_set.next() { rows.push(mysql_row_to_strings(row.map_err(map_mysql_error)?)); } - last_columns = columns; - last_rows = rows; + if columns.is_empty() { + last_rows_affected = rows_affected; + } else { + last_columns = columns; + last_rows = rows; + } } if last_columns.is_empty() { @@ -925,6 +946,24 @@ mod tests { assert_eq!(error.to_string(), "mysql authentication failed"); } + #[test] + fn mysql_connection_enforces_utf8mb4_session() { + let opts: mysql::Opts = + mysql_opts_builder("127.0.0.1", 3306, Some("dbtool"), Some("secret"), "qa_demo").into(); + + assert_eq!( + opts.get_init(), + vec![String::from(MYSQL_UTF8MB4_SESSION_INIT)] + ); + } + + #[test] + fn mysql_string_values_preserve_multilingual_utf8() { + let rendered = mysql_value_to_string(&MySqlValue::Bytes("张敏😀".as_bytes().to_vec())); + + assert_eq!(rendered, "张敏😀"); + } + #[test] fn sqlite_driver_supports_connect_inspect_and_query() { let unique = SystemTime::now() diff --git a/examples/scripts/bootstrap-mysql.sh b/examples/scripts/bootstrap-mysql.sh index fccfe89..39644e0 100755 --- a/examples/scripts/bootstrap-mysql.sh +++ b/examples/scripts/bootstrap-mysql.sh @@ -4,10 +4,17 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.demo.yml}" SERVICE_NAME="${MYSQL_SERVICE_NAME:-mysql}" +CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-dbtool-cli-v1-mysql-1}" MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-dbtoolroot}" -docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ - mysql -uroot "-p$MYSQL_ROOT_PASSWORD" \ - < "$ROOT_DIR/examples/fixtures/mysql/init.sql" +if docker compose version >/dev/null 2>&1; then + docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ + mysql --default-character-set=utf8mb4 -uroot "-p$MYSQL_ROOT_PASSWORD" \ + < "$ROOT_DIR/examples/fixtures/mysql/init.sql" +else + docker exec -i "$CONTAINER_NAME" \ + mysql --default-character-set=utf8mb4 -uroot "-p$MYSQL_ROOT_PASSWORD" \ + < "$ROOT_DIR/examples/fixtures/mysql/init.sql" +fi echo "MySQL demo data seeded into qa_demo." diff --git a/examples/scripts/bootstrap-postgres.sh b/examples/scripts/bootstrap-postgres.sh index 41c8fb4..0a80a68 100755 --- a/examples/scripts/bootstrap-postgres.sh +++ b/examples/scripts/bootstrap-postgres.sh @@ -4,11 +4,18 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.demo.yml}" SERVICE_NAME="${POSTGRES_SERVICE_NAME:-postgres}" +CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-dbtool-cli-v1-postgres-1}" POSTGRES_USER="${POSTGRES_USER:-dbtool}" POSTGRES_DB="${POSTGRES_DB:-dbtool_demo}" -docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ - psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ - < "$ROOT_DIR/examples/fixtures/postgres/init.sql" +if docker compose version >/dev/null 2>&1; then + docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ + psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + < "$ROOT_DIR/examples/fixtures/postgres/init.sql" +else + docker exec -i "$CONTAINER_NAME" \ + psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + < "$ROOT_DIR/examples/fixtures/postgres/init.sql" +fi echo "PostgreSQL demo data seeded into $POSTGRES_DB." diff --git a/gui/README.md b/gui/README.md index f3e9252..de8b997 100644 --- a/gui/README.md +++ b/gui/README.md @@ -2,19 +2,52 @@ 本目录用于存放 `CMP-9` 的未来桌面端 GUI 基线产物,而不是正式应用代码。 +## 当前基线结论 + +- 当前仓库仍然没有正式 Web / Desktop 前端工程入口:没有 `package.json`、React 应用、Electron / Tauri 壳。 +- 当前已经存在最小展示层基础:`gui/prototype` 静态工作台原型、`gui/preview-server.mjs` 本地预览服务,以及 `apps/tui` 的终端工作台壳层。 +- 因此,当前前端可见面不再是“完全空白”,但仍属于 foundation 级别,不进入 CLI V1 产品范围。 + +## 首版 UI/UX 范围建议 + +当前最合理的首版未来 GUI 范围是单一数据库工作台,而不是多页面 dashboard: + +- **Connection Manager**:展示已保存连接、驱动类型、环境标签、连接健康状态,并支持测试连接和进入工作台 +- **Database Workspace**:固定为 schema browser、query editor、results/history/export 底部区域、右侧 inspector 的工作台布局 +- **Execution Feedback**:明确区分 `Running`、`Success`、`Empty`、`Error`、`Exported`,且执行反馈持续留在工作区内 +- **Export Flow**:围绕当前结果集提供格式选择、目标路径、覆盖提醒和导出结果反馈 + +当前刻意不做: + +- 正式 React / Electron / Tauri 工程 +- 真实数据库接入或后端契约改造 +- dashboard 首页、图表 BI、AI 助手、migration / schema editing + ## 当前产物 - `desktop-foundation.md`:信息架构、布局方向、组件清单、契约要求和 UI/UX 建议 - `prototype/index.html`:最小可见工作台原型 - `prototype/styles.css`:原型样式 -- `prototype/app.js`:原型内交互 +- `prototype/app.js`:原型内交互与状态切换 - `preview-server.mjs`:零依赖本地预览服务 +## 原型包含内容 + +- 连接管理、schema browser、query editor、results、export、inspector 的稳定工作台分区 +- 按连接切换不同的 schema catalog、SQL 草稿标签、结果列和默认导出路径 +- `Running` / `Success` / `Empty` / `Error` / `Exported` 五种可预览执行态 +- Light / Dark 主题切换 +- schema 搜索过滤、连接切换、结果 / 历史 / 导出 / 问题面板切换 +- 当前界面状态到共享结构化契约的映射预览,其中 `running` 明确标记为前端暂态 +- 键盘快捷路径:`1` / `2` / `3` / `4` / `5`、`R`、`T`、`/`、`⌘/Ctrl+K` + ## 查看方式 ### 方式一:直接打开 -直接用浏览器打开 `gui/prototype/index.html`。 +直接用浏览器打开 `gui/index.html`。 + +若希望直接进入工作台原型,也可以打开 `gui/prototype/index.html`。 ### 方式二:启动本地预览 @@ -30,11 +63,19 @@ node gui/preview-server.mjs http://127.0.0.1:4173 ``` +当前 `/` 会落到 `gui/index.html`,再从入口页进入工作台原型。 + ## 界面验收 验收人应重点确认: -1. 是否能看出连接管理、schema browser、query editor、results table、export feedback 的稳定分区 -2. 布局是否体现“工作台”而不是通用 dashboard -3. 状态、错误、执行反馈是否一眼可见 -4. 文档中的后端契约需求是否能映射到 CLI 已定义产品概念 +1. 是否能看出连接概览、工作区上下文、执行反馈三层信息同时存在 +2. 工作台布局是否保持“操作台”气质,而不是卡片拼贴式 dashboard +3. 成功、空结果、失败、导出完成四种状态是否都能一眼区分 +4. `Running` 态是否能在不离开工作区的前提下持续显示执行反馈,并暂时锁定导出动作 +5. 切换连接后,顶部摘要、中部工作区和右侧 inspector 是否同步更新 +6. 切换连接后,schema tree、query tab 标签、结果列与默认导出路径是否也同步切换到对应驱动上下文 +7. Light / Dark 两种主题下信息密度、对比度和可读性是否仍然稳定 +8. `⌘/Ctrl+K` 快捷操作面板是否能帮助 QA 快速预览状态与连接切换 +9. 文档中的后端契约需求是否仍可映射到 CLI 已定义产品概念 +10. `Contract` 面板是否能帮助 QA 与 Backend 快速核对 `query` / `export` envelope,以及 `running` 暂态边界 diff --git a/gui/desktop-foundation.md b/gui/desktop-foundation.md index e7aab27..f27cf7f 100644 --- a/gui/desktop-foundation.md +++ b/gui/desktop-foundation.md @@ -8,13 +8,13 @@ ### 真实现状 -- 当前仓库没有前端入口,也没有任何展示层基础。 -- 当前不存在 `package.json`、React 工程、桌面壳、页面路由、组件目录或样式系统。 +- 当前仓库没有正式前端工程入口:不存在 `package.json`、React 工程、桌面壳、页面路由、组件目录或样式系统。 +- 当前仓库已经存在展示层 foundation:`gui/prototype` 静态工作台原型、`gui/preview-server.mjs` 本地预览服务,以及 `apps/tui` 的终端工作台壳层。 - 当前项目仍以 CLI V1 为主路径,GUI 只允许做未来方向基线,不进入本期交付主链路。 ### 结论 -- GUI 第一轮最合理的交付形态应是“信息架构 + 组件边界 + 契约清单 + 静态原型”。 +- GUI 第一轮最合理的交付形态仍是“信息架构 + 组件边界 + 契约清单 + 静态原型”。 - 不应在本 issue 中直接引入 Electron、Tauri、React 或完整桌面工程。 - 后续 GUI 项目启动时,推荐使用 React + shadcn/ui + Tailwind CSS,并以本文件作为产品层输入。 @@ -250,6 +250,9 @@ Desktop GUI - 工作台结构是否稳定 - 连接管理与执行反馈是否易懂 - schema browser、query editor、results table、inspector 是否形成合理分区 +- success / empty / error / exported 等关键状态是否能被明确区分 +- 当前界面状态是否仍能映射回共享 `query` / `export` 契约,且 `running` 被识别为前端暂态 +- light / dark 主题下的结构层次是否保持稳定 该原型不是正式桌面应用,也不接真实数据源。 diff --git a/gui/index.html b/gui/index.html new file mode 100644 index 0000000..1413cd1 --- /dev/null +++ b/gui/index.html @@ -0,0 +1,77 @@ + + + + + + dbtool GUI Foundation Entry + + + +
+
+
dbtool-cli-v1 frontend foundation
+

Future GUI Workspace Entry

+

+ 当前仓库没有正式 React / Desktop 前端工程,但已经有可预览的最小展示层: + 一个数据库工作台原型、一个本地预览服务,以及与 CLI/TUI 对齐的首版 UI/UX 边界。 +

+ + +
+ +
+
+

Current Surface

+

已有展示层基础

+
    +
  • `gui/prototype` 静态工作台原型
  • +
  • `gui/preview-server.mjs` 本地预览服务
  • +
  • `apps/tui` 终端工作台验证路径
  • +
+
+ +
+

MVP Scope

+

首版只收口单一工作台

+
    +
  • Connection Manager
  • +
  • Schema Browser
  • +
  • Query Editor
  • +
  • Results / History / Export
  • +
  • Inspector 与执行状态
  • +
+
+ +
+

Run

+

两种预览方式

+
    +
  1. 直接打开 `gui/index.html` 或 `gui/prototype/index.html`
  2. +
  3. 运行 `node gui/preview-server.mjs` 后访问 `http://127.0.0.1:4173`
  4. +
+
+ +
+

Acceptance

+

QA 重点确认

+
    +
  • 连接、工作区和执行反馈三层信息同时可见
  • +
  • 成功、空结果、失败、导出完成能一眼区分
  • +
  • 切换连接后摘要、工作区、Inspector 同步更新
  • +
  • Light / Dark 主题下仍保持稳定信息密度
  • +
+
+
+ + +
+ + diff --git a/gui/landing.css b/gui/landing.css new file mode 100644 index 0000000..1a53434 --- /dev/null +++ b/gui/landing.css @@ -0,0 +1,156 @@ +:root { + color-scheme: dark; + --bg: #0f1319; + --bg-elevated: #151b23; + --panel: #171e27; + --panel-strong: #202938; + --border: #2b3645; + --text: #e8edf5; + --muted: #9cabbe; + --accent: #8bb8ff; + --accent-soft: rgba(139, 184, 255, 0.14); + --shadow: 0 22px 48px rgba(0, 0, 0, 0.22); + --radius: 20px; + --radius-sm: 14px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: linear-gradient(180deg, #171e27 0%, var(--bg) 100%); + color: var(--text); + font-family: var(--font); +} + +.landing-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 32px 0 40px; +} + +.hero, +.card, +.footer-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel) 92%, white 8%); + box-shadow: var(--shadow); +} + +.hero { + padding: 32px; +} + +.hero__badge, +.card__eyebrow { + color: var(--muted); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.hero h1, +.card h2 { + margin: 8px 0 0; + letter-spacing: -0.02em; +} + +.hero h1 { + font-size: clamp(32px, 5vw, 52px); +} + +.hero__copy, +.footer-card p, +.card li, +.card ol { + color: var(--muted); + line-height: 1.65; +} + +.hero__copy { + max-width: 760px; + margin: 16px 0 0; + font-size: 15px; +} + +.hero__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.primary-link, +.secondary-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; +} + +.primary-link { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: var(--accent-soft); +} + +.secondary-link { + background: var(--panel-strong); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.card { + padding: 22px 24px; +} + +.card ul, +.card ol { + margin: 14px 0 0; + padding-left: 18px; +} + +.card li + li { + margin-top: 8px; +} + +.footer-card { + margin-top: 16px; + padding: 18px 24px; +} + +code { + font-family: "SFMono-Regular", ui-monospace, monospace; + font-size: 0.95em; +} + +@media (max-width: 840px) { + .landing-shell { + width: min(100vw - 24px, 1180px); + padding-top: 20px; + } + + .hero, + .card, + .footer-card { + border-radius: 18px; + } + + .grid { + grid-template-columns: 1fr; + } +} diff --git a/gui/preview-server.mjs b/gui/preview-server.mjs index a1c013a..0cf3a89 100644 --- a/gui/preview-server.mjs +++ b/gui/preview-server.mjs @@ -2,7 +2,7 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, join, normalize } from "node:path"; -const root = new URL("./prototype/", import.meta.url); +const root = new URL("./", import.meta.url); const port = Number(process.env.GUI_PREVIEW_PORT || 4173); const contentTypes = { diff --git a/gui/prototype/app.js b/gui/prototype/app.js index 8e25fe6..200af61 100644 --- a/gui/prototype/app.js +++ b/gui/prototype/app.js @@ -1,53 +1,1364 @@ +const STATE_ORDER = ["running", "success", "empty", "error", "exported"]; +const QUERY_ORDER = ["success", "empty", "error", "export"]; + +const workspaceFixtures = { + "pg-prod-readonly": { + defaultSelection: "public.orders", + catalog: [ + { + name: "public", + items: [ + { + name: "orders", + kind: "Table", + descriptor: "table · 12 columns", + columns: 12, + primaryKey: "id", + search: "orders public table created_at status customer_id", + }, + { + name: "customers", + kind: "Table", + descriptor: "table · 9 columns", + columns: 9, + primaryKey: "id", + search: "customers public table email country", + }, + { + name: "payments", + kind: "Table", + descriptor: "table · 7 columns", + columns: 7, + primaryKey: "id", + search: "payments public table status amount", + }, + ], + }, + { + name: "analytics", + items: [ + { + name: "daily_orders", + kind: "View", + descriptor: "view · materialized summary", + columns: 6, + primaryKey: "—", + search: "daily_orders analytics view", + }, + { + name: "top_customers", + kind: "View", + descriptor: "view · ranking output", + columns: 5, + primaryKey: "—", + search: "top_customers analytics view", + }, + ], + }, + { + name: "ops", + items: [ + { + name: "job_runs", + kind: "Table", + descriptor: "table · execution log", + columns: 8, + primaryKey: "id", + search: "job_runs ops table status finished_at", + }, + { + name: "incident_log", + kind: "Table", + descriptor: "table · support evidence", + columns: 10, + primaryKey: "id", + search: "incident_log ops table created_at severity", + }, + ], + }, + ], + queries: { + success: { + label: "orders audit.sql", + selection: "public.orders", + sql: `select + id, + customer_id, + status, + created_at +from public.orders +where created_at > now() - interval '7 days' +order by created_at desc +limit 50;`, + settledState: "success", + columns: ["id", "customer_id", "status", "created_at"], + successRows: [ + ["8ec1...", "7cb3...", "paid", "2026-03-25 13:55:01 UTC"], + ["80f8...", "5d10...", "processing", "2026-03-25 13:44:15 UTC"], + ["5aa2...", "9ab1...", "failed", "2026-03-25 13:12:48 UTC"], + ["44fe...", "7cb3...", "paid", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 50, + successDuration: "182 ms", + emptyDuration: "95 ms", + errorDuration: "21 ms", + errorMessage: "Syntax error near `form`", + exportFormat: "csv", + exportPath: "/tmp/orders-last-7-days.csv", + }, + empty: { + label: "empty result.sql", + selection: "public.orders", + sql: `select + id, + status, + created_at +from public.orders +where created_at > now() - interval '5 minutes' + and status = 'refunded' +order by created_at desc;`, + settledState: "empty", + columns: ["id", "status", "created_at"], + successRows: [], + successRowCount: 0, + successDuration: "95 ms", + emptyDuration: "95 ms", + errorDuration: "18 ms", + errorMessage: "No result envelope available", + exportFormat: "csv", + exportPath: "/tmp/orders-empty.csv", + }, + error: { + label: "bad syntax.sql", + selection: "public.orders", + sql: `select + id, + status +form public.orders +where status = 'paid';`, + settledState: "error", + columns: ["id", "status"], + successRows: [], + successRowCount: 0, + successDuration: "—", + emptyDuration: "—", + errorDuration: "21 ms", + errorMessage: "Syntax error near `form`", + exportFormat: "csv", + exportPath: "/tmp/orders-error.csv", + }, + export: { + label: "export review.sql", + selection: "public.orders", + sql: `select + id, + customer_id, + status, + created_at +from public.orders +where created_at >= date_trunc('day', now()) +order by created_at desc;`, + settledState: "exported", + columns: ["id", "customer_id", "status", "created_at"], + successRows: [ + ["8ec1...", "7cb3...", "paid", "2026-03-25 13:55:01 UTC"], + ["80f8...", "5d10...", "processing", "2026-03-25 13:44:15 UTC"], + ["5aa2...", "9ab1...", "failed", "2026-03-25 13:12:48 UTC"], + ["44fe...", "7cb3...", "paid", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 50, + successDuration: "182 ms", + emptyDuration: "95 ms", + errorDuration: "21 ms", + errorMessage: "Export source query failed", + exportFormat: "csv", + exportPath: "/tmp/orders-last-7-days.csv", + }, + }, + }, + "mysql-staging": { + defaultSelection: "qa_demo.orders", + catalog: [ + { + name: "qa_demo", + items: [ + { + name: "orders", + kind: "Table", + descriptor: "table · 11 columns", + columns: 11, + primaryKey: "id", + search: "orders qa_demo table created_at status region", + }, + { + name: "order_audit", + kind: "Table", + descriptor: "table · change history", + columns: 8, + primaryKey: "audit_id", + search: "order_audit qa_demo table before after actor", + }, + { + name: "refunds", + kind: "Table", + descriptor: "table · finance review", + columns: 6, + primaryKey: "id", + search: "refunds qa_demo table amount processed_at", + }, + ], + }, + { + name: "reporting", + items: [ + { + name: "daily_orders", + kind: "View", + descriptor: "view · daily summary", + columns: 5, + primaryKey: "—", + search: "daily_orders reporting view", + }, + { + name: "refund_watchlist", + kind: "View", + descriptor: "view · issue triage", + columns: 4, + primaryKey: "—", + search: "refund_watchlist reporting view", + }, + ], + }, + { + name: "ops", + items: [ + { + name: "job_runs", + kind: "Table", + descriptor: "table · pipeline status", + columns: 7, + primaryKey: "id", + search: "job_runs ops table status finished_at", + }, + { + name: "dead_letter_queue", + kind: "Table", + descriptor: "table · retry backlog", + columns: 5, + primaryKey: "id", + search: "dead_letter_queue ops table retry backlog", + }, + ], + }, + ], + queries: { + success: { + label: "staging diff.sql", + selection: "qa_demo.orders", + sql: `select + id, + region, + status, + updated_at +from qa_demo.orders +where updated_at >= now() - interval 1 day +order by updated_at desc +limit 50;`, + settledState: "success", + columns: ["id", "region", "status", "updated_at"], + successRows: [ + ["1024", "us-east", "paid", "2026-03-25 13:55:01 UTC"], + ["1021", "eu-west", "processing", "2026-03-25 13:44:15 UTC"], + ["1015", "ap-south", "failed", "2026-03-25 13:12:48 UTC"], + ["1007", "us-west", "paid", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 37, + successDuration: "164 ms", + emptyDuration: "88 ms", + errorDuration: "17 ms", + errorMessage: "Unknown column `statsu` in `field list`", + exportFormat: "json", + exportPath: "/tmp/mysql-staging-diff.json", + }, + empty: { + label: "no drift.sql", + selection: "reporting.daily_orders", + sql: `select + order_date, + failed_count +from reporting.daily_orders +where order_date = current_date() + and failed_count > 50;`, + settledState: "empty", + columns: ["order_date", "failed_count"], + successRows: [], + successRowCount: 0, + successDuration: "88 ms", + emptyDuration: "88 ms", + errorDuration: "16 ms", + errorMessage: "No result envelope available", + exportFormat: "json", + exportPath: "/tmp/mysql-no-drift.json", + }, + error: { + label: "bad filter.sql", + selection: "qa_demo.orders", + sql: `select + id, + statsu +from qa_demo.orders +where status = 'paid';`, + settledState: "error", + columns: ["id", "status"], + successRows: [], + successRowCount: 0, + successDuration: "—", + emptyDuration: "—", + errorDuration: "17 ms", + errorMessage: "Unknown column `statsu` in `field list`", + exportFormat: "json", + exportPath: "/tmp/mysql-error.json", + }, + export: { + label: "handoff export.sql", + selection: "qa_demo.order_audit", + sql: `select + audit_id, + order_id, + actor, + changed_at +from qa_demo.order_audit +order by changed_at desc +limit 100;`, + settledState: "exported", + columns: ["audit_id", "order_id", "actor", "changed_at"], + successRows: [ + ["5008", "1024", "api", "2026-03-25 13:55:01 UTC"], + ["5007", "1021", "worker", "2026-03-25 13:44:15 UTC"], + ["5006", "1015", "ops", "2026-03-25 13:12:48 UTC"], + ["5005", "1007", "api", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 100, + successDuration: "201 ms", + emptyDuration: "112 ms", + errorDuration: "22 ms", + errorMessage: "Export source query failed", + exportFormat: "json", + exportPath: "/tmp/mysql-audit-export.json", + }, + }, + }, + "sqlite-local": { + defaultSelection: "main.audit_events", + catalog: [ + { + name: "main", + items: [ + { + name: "audit_events", + kind: "Table", + descriptor: "table · local evidence log", + columns: 7, + primaryKey: "id", + search: "audit_events main table action actor event_time", + }, + { + name: "attachments", + kind: "Table", + descriptor: "table · exported evidence", + columns: 5, + primaryKey: "id", + search: "attachments main table file_path created_at", + }, + { + name: "query_history", + kind: "Table", + descriptor: "table · recent drafts", + columns: 4, + primaryKey: "id", + search: "query_history main table sql_label saved_at", + }, + ], + }, + { + name: "temp", + items: [ + { + name: "staged_rows", + kind: "Table", + descriptor: "table · transient compare set", + columns: 4, + primaryKey: "—", + search: "staged_rows temp table transient compare", + }, + ], + }, + ], + queries: { + success: { + label: "evidence review.sql", + selection: "main.audit_events", + sql: `select + id, + actor, + action, + event_time +from main.audit_events +order by event_time desc +limit 50;`, + settledState: "success", + columns: ["id", "actor", "action", "event_time"], + successRows: [ + ["91", "qa-bot", "attach_export", "2026-03-25 13:55:01 UTC"], + ["90", "frontend", "query_ok", "2026-03-25 13:44:15 UTC"], + ["89", "backend", "inspect_table", "2026-03-25 13:12:48 UTC"], + ["88", "qa-bot", "open_file", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 24, + successDuration: "14 ms", + emptyDuration: "8 ms", + errorDuration: "4 ms", + errorMessage: "SQLite error: no such column: acton", + exportFormat: "csv", + exportPath: "/tmp/sqlite-audit-events.csv", + }, + empty: { + label: "no incidents.sql", + selection: "main.audit_events", + sql: `select + id, + action, + event_time +from main.audit_events +where action = 'incident_opened' + and event_time >= datetime('now', '-5 minutes');`, + settledState: "empty", + columns: ["id", "action", "event_time"], + successRows: [], + successRowCount: 0, + successDuration: "8 ms", + emptyDuration: "8 ms", + errorDuration: "4 ms", + errorMessage: "No result envelope available", + exportFormat: "csv", + exportPath: "/tmp/sqlite-empty.csv", + }, + error: { + label: "bad column.sql", + selection: "main.audit_events", + sql: `select + id, + acton +from main.audit_events +where actor = 'qa-bot';`, + settledState: "error", + columns: ["id", "action"], + successRows: [], + successRowCount: 0, + successDuration: "—", + emptyDuration: "—", + errorDuration: "4 ms", + errorMessage: "SQLite error: no such column: acton", + exportFormat: "csv", + exportPath: "/tmp/sqlite-error.csv", + }, + export: { + label: "attachment export.sql", + selection: "main.attachments", + sql: `select + id, + file_path, + created_at +from main.attachments +order by created_at desc +limit 25;`, + settledState: "exported", + columns: ["id", "file_path", "created_at"], + successRows: [ + ["17", "/tmp/evidence-1.csv", "2026-03-25 13:55:01 UTC"], + ["16", "/tmp/evidence-2.csv", "2026-03-25 13:44:15 UTC"], + ["15", "/tmp/evidence-3.csv", "2026-03-25 13:12:48 UTC"], + ["14", "/tmp/evidence-4.csv", "2026-03-25 12:58:22 UTC"], + ], + successRowCount: 25, + successDuration: "11 ms", + emptyDuration: "6 ms", + errorDuration: "4 ms", + errorMessage: "Export source query failed", + exportFormat: "csv", + exportPath: "/tmp/sqlite-attachments.csv", + }, + }, + }, +}; + const connectionCards = document.querySelectorAll(".connection-card"); +const bottomTabs = document.querySelectorAll(".bottom-tab"); +const tabContents = document.querySelectorAll(".tab-content"); + const activeConnection = document.getElementById("active-connection"); +const driverPill = document.getElementById("driver-pill"); const detailTarget = document.getElementById("detail-target"); -const runQueryButton = document.getElementById("run-query"); +const detailMode = document.getElementById("detail-mode"); +const detailExport = document.getElementById("detail-export"); +const detailTheme = document.getElementById("detail-theme"); +const contextTarget = document.getElementById("context-target"); +const contextMode = document.getElementById("context-mode"); +const contextSelection = document.getElementById("context-selection"); +const contextValidated = document.getElementById("context-validated"); +const inspectorObject = document.getElementById("inspector-object"); +const selectionKind = document.getElementById("selection-kind"); +const selectionPrimaryKey = document.getElementById("selection-primary-key"); +const selectionColumns = document.getElementById("selection-columns"); +const summaryWorkspace = document.getElementById("summary-workspace"); +const summaryWorkspaceCopy = document.getElementById("summary-workspace-copy"); +const summarySelection = document.getElementById("summary-selection"); +const summarySelectionCopy = document.getElementById("summary-selection-copy"); +const summaryRun = document.getElementById("summary-run"); +const summaryRunCopy = document.getElementById("summary-run-copy"); +const summaryExport = document.getElementById("summary-export"); +const summaryExportCopy = document.getElementById("summary-export-copy"); const executionBanner = document.getElementById("execution-banner"); const executionTitle = document.getElementById("execution-title"); const executionCopy = document.getElementById("execution-copy"); const durationValue = document.getElementById("duration-value"); const rowCount = document.getElementById("row-count"); -const bottomTabs = document.querySelectorAll(".bottom-tab"); -const tabContents = document.querySelectorAll(".tab-content"); +const stateLabel = document.getElementById("state-label"); +const editorCode = document.getElementById("editor-code"); +const editorGutter = document.getElementById("editor-gutter"); +const toolbarTabsContainer = document.getElementById("toolbar-tabs"); +const schemaTree = document.getElementById("schema-tree"); +const resultsHead = document.getElementById("results-head"); +const resultsBody = document.getElementById("results-body"); +const historyList = document.getElementById("history-list"); +const resultSummary = document.getElementById("result-summary"); +const resultCopy = document.getElementById("result-copy"); +const exportTitle = document.getElementById("export-title"); +const exportCopy = document.getElementById("export-copy"); +const problemTitle = document.getElementById("problem-title"); +const problemCopy = document.getElementById("problem-copy"); +const contractTitle = document.getElementById("contract-title"); +const contractCopy = document.getElementById("contract-copy"); +const contractJson = document.getElementById("contract-json"); +const statusMessage = document.getElementById("status-message"); +const themeToggle = document.getElementById("theme-toggle"); +const schemaSearch = document.getElementById("schema-search"); +const clearSearch = document.getElementById("clear-search"); +const runQueryButton = document.getElementById("run-query"); +const cycleStateButton = document.getElementById("cycle-state"); +const exportCurrentButton = document.getElementById("export-current"); +const exportCsvButton = document.getElementById("export-csv"); +const exportJsonButton = document.getElementById("export-json"); +const commandButton = document.getElementById("command-button"); +const commandPalette = document.getElementById("command-palette"); +const commandBackdrop = document.getElementById("command-backdrop"); +const commandCloseButton = document.getElementById("command-close"); +const commandStateButtons = document.querySelectorAll("[data-command-state]"); +const commandConnectionButtons = document.querySelectorAll("[data-command-connection]"); +const commandActionButtons = document.querySelectorAll("[data-command-action]"); -connectionCards.forEach((card) => { - card.addEventListener("click", () => { - connectionCards.forEach((item) => item.classList.remove("is-active")); - card.classList.add("is-active"); +const appState = { + connection: "pg-prod-readonly", + query: "success", + selection: "public.orders", + activeState: "success", + activeTab: "results", + runTimer: null, +}; - const target = card.dataset.connection; - activeConnection.textContent = target; - detailTarget.textContent = target; +function getActiveConnectionCard() { + return document.querySelector(`.connection-card[data-connection="${appState.connection}"]`); +} + +function getActiveConnectionData() { + return getActiveConnectionCard().dataset; +} + +function getWorkspaceFixture() { + return workspaceFixtures[appState.connection]; +} + +function getQueryPreset(queryKey = appState.query) { + return getWorkspaceFixture().queries[queryKey]; +} + +function getSelectionMeta(selection = appState.selection) { + const fixture = getWorkspaceFixture(); + + for (const group of fixture.catalog) { + for (const item of group.items) { + if (`${group.name}.${item.name}` === selection) { + return { + schema: group.name, + selection, + ...item, + }; + } + } + } + + return null; +} + +function getTreeItems() { + return schemaTree.querySelectorAll(".tree-item"); +} + +function getSchemaGroups() { + return schemaTree.querySelectorAll(".tree-group"); +} + +function setActiveTab(tabName) { + appState.activeTab = tabName; + bottomTabs.forEach((tab) => { + tab.classList.toggle("is-active", tab.dataset.tab === tabName); }); -}); + tabContents.forEach((content) => { + content.classList.toggle("is-active", content.dataset.content === tabName); + }); +} -runQueryButton.addEventListener("click", () => { - const isError = executionBanner.classList.contains("is-error"); +function renderToolbarTabs() { + const fixture = getWorkspaceFixture(); - if (isError) { - executionBanner.classList.remove("is-error"); - executionTitle.textContent = "Last run succeeded"; - executionCopy.textContent = "50 rows returned in 182 ms. Export is available."; - durationValue.textContent = "182 ms"; - rowCount.textContent = "50"; + toolbarTabsContainer.innerHTML = QUERY_ORDER.map((queryKey) => { + const preset = fixture.queries[queryKey]; + const isActive = queryKey === appState.query ? " is-active" : ""; + return ``; + }).join(""); +} + +function renderSchemaTree() { + const fixture = getWorkspaceFixture(); + + schemaTree.innerHTML = fixture.catalog + .map((group) => { + const groupSearch = [group.name, ...group.items.map((item) => item.search)].join(" ").toLowerCase(); + const items = group.items + .map((item) => { + const selection = `${group.name}.${item.name}`; + const isSelected = selection === appState.selection ? " is-selected" : ""; + return ` + + `; + }) + .join(""); + + return ` +
+ ${group.name} + ${items} +
+ `; + }) + .join(""); + + applySchemaFilter(); +} + +function renderResults(columns, rows, tableMessage) { + const activeColumns = columns.length > 0 ? columns : ["message"]; + resultsHead.innerHTML = activeColumns.map((column) => `
`).join(""); + + if (tableMessage) { + resultsBody.innerHTML = ``; return; } - executionBanner.classList.add("is-error"); - executionTitle.textContent = "Last run failed"; - executionCopy.textContent = "Syntax error near `form`. The error summary should stay visible in the workspace."; - durationValue.textContent = "21 ms"; - rowCount.textContent = "0"; + resultsBody.innerHTML = rows + .map( + (row) => ` + + ${row.map((cell) => ``).join("")} + + `, + ) + .join(""); +} + +function renderHistory(items) { + historyList.innerHTML = items + .map( + ([title, meta]) => ` +
+ ${title} + ${meta} +
+ `, + ) + .join(""); +} + +function renderEditor(sql) { + editorCode.textContent = sql; + const lineCount = sql.split("\n").length; + editorGutter.innerHTML = Array.from({ length: lineCount }, (_, index) => `${index + 1}`).join(""); +} + +function normalizeDriver(driver) { + const driverMap = { + PostgreSQL: "postgres", + MySQL: "mysql", + SQLite: "sqlite", + }; + + return driverMap[driver] || driver.toLowerCase(); +} + +function buildHistoryMeta(queryKey, state = getQueryPreset(queryKey).settledState) { + const query = getQueryPreset(queryKey); + const connection = getActiveConnectionData(); + + if (state === "running") { + return `Running · executing against ${connection.connection}`; + } + + if (state === "success") { + return `Success · ${query.successDuration} · ${query.successRowCount} rows`; + } + + if (state === "empty") { + return `Success · ${query.emptyDuration} · 0 rows`; + } + + if (state === "error") { + return `Error · ${query.errorMessage}`; + } + + return `Exported · ${query.exportFormat.toUpperCase()} · ${query.successRowCount} rows`; +} + +function buildHistory() { + const orderedKeys = [appState.query, ...QUERY_ORDER.filter((queryKey) => queryKey !== appState.query)]; + + return orderedKeys.map((queryKey) => { + const preset = getQueryPreset(queryKey); + const state = queryKey === appState.query ? appState.activeState : preset.settledState; + return [preset.label, buildHistoryMeta(queryKey, state)]; + }); +} + +function buildContractSnapshot() { + const connection = getActiveConnectionData(); + const query = getQueryPreset(); + const selection = getSelectionMeta(); + const baseTarget = { + profile_name: connection.connection, + driver: normalizeDriver(connection.driver), + }; + + if (appState.activeState === "running") { + return { + title: "UI transient execution preview", + copy: "`running` 仅是界面暂态;共享契约仍等待 `QueryResponse` 或 `AppError` 返回。", + snapshot: { + ui_state: "running", + pending_operation: "query", + target: baseTarget, + source: { + kind: "inline", + label: query.label, + }, + context_object: selection?.selection || appState.selection, + note: "Transient frontend state before the settled backend envelope is available.", + }, + }; + } + + if (appState.activeState === "error") { + return { + title: "Query error envelope preview", + copy: "失败态映射到 `status: error`,重点看 `error.kind` 与 `error.message`,而不是把失败伪装成空结果。", + snapshot: { + status: "error", + operation: "query", + error: { + operation: "query", + kind: "query", + message: query.errorMessage, + }, + context_object: selection?.selection || appState.selection, + }, + }; + } + + if (appState.activeState === "exported") { + return { + title: "Export success envelope preview", + copy: "导出完成后切换到 `operation: export`;QA 可重点核对 `export.output_path` 与 `row_count`。", + snapshot: { + status: "success", + operation: "export", + data: { + target: baseTarget, + source: { + kind: "inline", + label: query.label, + }, + context_object: selection?.selection || appState.selection, + export: { + format: query.exportFormat, + output_path: query.exportPath, + overwrite: false, + }, + row_count: query.successRowCount, + }, + }, + }; + } + + if (appState.activeState === "empty") { + return { + title: "Query empty-result envelope preview", + copy: "空结果仍然属于 `status: success`;用 `data.result.row_count: 0` 与问题态区分。", + snapshot: { + status: "success", + operation: "query", + data: { + target: baseTarget, + source: { + kind: "inline", + label: query.label, + }, + context_object: selection?.selection || appState.selection, + result: { + columns: query.columns, + rows: [], + row_count: 0, + }, + }, + }, + }; + } + + return { + title: "Query success envelope preview", + copy: "成功态对齐 `operation: query`,重点确认 `data.result.columns`、`rows` 与 `row_count` 能驱动工作区渲染。", + snapshot: { + status: "success", + operation: "query", + data: { + target: baseTarget, + source: { + kind: "inline", + label: query.label, + }, + context_object: selection?.selection || appState.selection, + result: { + columns: query.columns, + rows: query.successRows.slice(0, 2), + row_count: query.successRowCount, + }, + }, + }, + }; +} + +function buildStatePreset() { + const query = getQueryPreset(); + const connection = getActiveConnectionData(); + const activeLabel = query.label; + + if (appState.activeState === "running") { + return { + title: "Query is running", + copy: `${activeLabel} stays visible in-place while the workspace keeps target, selection, and export context readable.`, + duration: "—", + rows: "—", + label: "Running", + resultSummary: `Execution in progress · ${activeLabel}`, + resultCopy: "执行中时不隐藏结果区,而是保留当前 SQL、目标连接和对象浏览上下文。", + exportTitle: "Export is unavailable while the query is running", + exportCopy: "等待当前执行完成后,再决定是否导出当前结果集。", + problemTitle: "No blocking problem yet", + problemCopy: "如果执行时间异常增长,未来正式 GUI 应提供 cancel / timeout 反馈。", + tableMessage: `${activeLabel} is running… results will appear here when the execution finishes.`, + rowsData: [], + runSummary: "Running · waiting for rows", + runCopy: `${connection.connection} 正在执行当前草稿。`, + lastExport: "Waiting for current run", + statusMessage: `Running preview active for ${activeLabel}.`, + columns: query.columns, + }; + } + + if (appState.activeState === "success") { + return { + title: "Last run succeeded", + copy: `${query.successRowCount} rows returned in ${query.successDuration}. Export is available.`, + duration: query.successDuration, + rows: String(query.successRowCount), + label: "Success", + resultSummary: `${query.successRowCount} rows · ${activeLabel}`, + resultCopy: "结果区保留紧邻执行反馈的上下文,不让表格单独承担状态信息。", + exportTitle: "Ready to export the current result set", + exportCopy: `\`${query.exportPath}\` is available as the default target.`, + problemTitle: "No blocking problem", + problemCopy: "当前成功态下,问题区保留错误说明预留位,但不制造多余警告。", + rowsData: query.successRows, + runSummary: `Success · ${query.successRowCount} rows`, + runCopy: `${query.successDuration},结果可导出。`, + lastExport: "Not started", + statusMessage: `Success preview active for ${activeLabel}.`, + columns: query.columns, + }; + } + + if (appState.activeState === "empty") { + return { + title: "Query returned no rows", + copy: `Execution still succeeded in ${query.emptyDuration}. Empty result should not look like a failure.`, + duration: query.emptyDuration, + rows: "0", + label: "Empty", + resultSummary: `0 rows · ${activeLabel}`, + resultCopy: "空结果是可解释的完成态,仍保留 SQL、耗时和连接上下文。", + exportTitle: "No rows to export from the current result", + exportCopy: "保持导出入口可见,但要清楚说明当前不会产出文件。", + problemTitle: "No blocking problem", + problemCopy: "如需下一步,可建议用户放宽过滤条件或切换对象,而不是显示错误。", + tableMessage: "No rows in the current result set.", + rowsData: [], + runSummary: "Success · 0 rows", + runCopy: `${query.emptyDuration},空结果但执行成功。`, + lastExport: "Skipped · no rows", + statusMessage: `Empty-result preview active for ${activeLabel}.`, + columns: query.columns, + }; + } + + if (appState.activeState === "error") { + return { + title: "Last run failed", + copy: `${query.errorMessage}. The error summary should stay visible in the workspace.`, + duration: query.errorDuration, + rows: "0", + label: "Error", + resultSummary: `No table output · ${activeLabel}`, + resultCopy: "错误信息应与 SQL 片段、目标连接和恢复动作放在同一工作区上下文中。", + exportTitle: "Export disabled until the query succeeds", + exportCopy: "失败态下导出区不隐藏,而是明确说明为何当前不可导出。", + problemTitle: query.errorMessage, + problemCopy: "建议动作:回到编辑区修正关键字、重试执行,或切换到最近一次成功结果。", + tableMessage: "Execution failed before a result set was returned.", + rowsData: [], + runSummary: "Error · query failed", + runCopy: `${query.errorDuration},错误摘要持续可见。`, + lastExport: "Blocked by query error", + statusMessage: `Error preview active for ${activeLabel}.`, + columns: query.columns, + }; + } + + return { + title: "Export completed", + copy: `${query.successRowCount} rows exported to \`${query.exportPath}\`. The result state remains reviewable.`, + duration: query.successDuration, + rows: String(query.successRowCount), + label: "Exported", + resultSummary: `${query.successRowCount} rows · ${activeLabel}`, + resultCopy: "导出完成后仍保留结果上下文,避免把确认信息丢到瞬时 toast。", + exportTitle: "Export completed successfully", + exportCopy: `\`${query.exportPath}\` was prepared as the last output path in this prototype.`, + problemTitle: "No blocking problem", + problemCopy: "导出成功是工作流完成态的一部分,应与执行成功和结果表格形成闭环。", + rowsData: query.successRows, + runSummary: `Exported · ${query.successRowCount} rows`, + runCopy: `${query.exportFormat.toUpperCase()} 导出反馈已留在工作台中。`, + lastExport: query.exportPath, + statusMessage: `Export preview active for ${activeLabel}.`, + columns: query.columns, + }; +} + +function renderSelection() { + const selection = getSelectionMeta(); + + if (!selection) { + return; + } + + contextSelection.textContent = selection.selection; + inspectorObject.textContent = selection.selection; + selectionKind.textContent = selection.kind; + selectionPrimaryKey.textContent = selection.primaryKey; + selectionColumns.textContent = String(selection.columns); + summarySelection.textContent = selection.selection; + summarySelectionCopy.textContent = `${selection.descriptor} · schema browser 与 inspector 保持同步。`; +} + +function renderConnection() { + const connection = getActiveConnectionData(); + + activeConnection.textContent = connection.connection; + driverPill.textContent = connection.driver; + detailTarget.textContent = connection.connection; + detailMode.textContent = connection.mode; + contextTarget.textContent = connection.connection; + contextMode.textContent = connection.mode; + contextValidated.textContent = connection.lastValidated; + summaryWorkspace.textContent = connection.workspace; + summaryWorkspaceCopy.textContent = connection.workspaceCopy; +} + +function renderThemeLabel() { + const theme = document.documentElement.dataset.theme; + const isDark = theme === "dark"; + themeToggle.textContent = isDark ? "切换到 Light" : "切换到 Dark"; + detailTheme.textContent = isDark ? "Dark" : "Light"; +} + +function renderState() { + const preset = buildStatePreset(); + const contract = buildContractSnapshot(); + const exportBlocked = appState.activeState === "running" || appState.activeState === "error"; + + executionBanner.className = `execution-banner is-${appState.activeState}`; + executionTitle.textContent = preset.title; + executionCopy.textContent = preset.copy; + durationValue.textContent = preset.duration; + rowCount.textContent = preset.rows; + stateLabel.textContent = preset.label; + resultSummary.textContent = preset.resultSummary; + resultCopy.textContent = preset.resultCopy; + exportTitle.textContent = preset.exportTitle; + exportCopy.textContent = preset.exportCopy; + problemTitle.textContent = preset.problemTitle; + problemCopy.textContent = preset.problemCopy; + contractTitle.textContent = contract.title; + contractCopy.textContent = contract.copy; + contractJson.textContent = JSON.stringify(contract.snapshot, null, 2); + summaryRun.textContent = preset.runSummary; + summaryRunCopy.textContent = preset.runCopy; + summaryExport.textContent = preset.lastExport === "Not started" ? getQueryPreset().exportPath : preset.lastExport; + summaryExportCopy.textContent = + preset.lastExport === "Not started" + ? "当前展示默认导出目标,不执行真实写盘。" + : "导出反馈保留在当前工作台,便于 QA 与 PM 直接复核。"; + detailExport.textContent = preset.lastExport; + statusMessage.textContent = preset.statusMessage; + runQueryButton.disabled = appState.activeState === "running"; + runQueryButton.textContent = appState.activeState === "running" ? "Running…" : "Run Query"; + exportCurrentButton.disabled = exportBlocked; + exportCsvButton.disabled = exportBlocked; + exportJsonButton.disabled = exportBlocked; + + renderResults(preset.columns, preset.rowsData, preset.tableMessage); + renderHistory(buildHistory()); + + if (appState.activeState === "running") { + setActiveTab("results"); + } + + if (appState.activeState === "error") { + setActiveTab("problems"); + } + + if (appState.activeState === "exported") { + setActiveTab("export"); + } +} + +function renderQuery() { + const preset = getQueryPreset(); + renderEditor(preset.sql); + renderState(); +} + +function applySchemaFilter() { + const keyword = schemaSearch.value.trim().toLowerCase(); + const items = getTreeItems(); + const groups = getSchemaGroups(); + + items.forEach((item) => { + const matches = item.dataset.search.includes(keyword); + item.hidden = keyword.length > 0 && !matches; + }); + + groups.forEach((group) => { + const groupMatch = group.dataset.search.includes(keyword); + const hasVisibleChildren = Array.from(group.querySelectorAll(".tree-item")).some((item) => !item.hidden); + group.hidden = keyword.length > 0 && !groupMatch && !hasVisibleChildren; + + if (!group.hidden && keyword.length > 0) { + group.open = true; + } + }); +} + +function renderAll() { + renderToolbarTabs(); + renderSchemaTree(); + renderConnection(); + renderSelection(); + renderThemeLabel(); + renderQuery(); + setActiveTab(appState.activeTab); +} + +function clearRunTimer() { + if (appState.runTimer) { + clearTimeout(appState.runTimer); + appState.runTimer = null; + } +} + +function closeCommandPalette() { + commandPalette.hidden = true; + document.body.classList.remove("has-modal"); +} + +function openCommandPalette() { + commandPalette.hidden = false; + document.body.classList.add("has-modal"); + statusMessage.textContent = "Quick actions open. Choose a state, connection, or workflow shortcut."; + commandStateButtons[0]?.focus(); +} + +function syncSelection(selection) { + appState.selection = selection; + renderSchemaTree(); + renderSelection(); +} + +function setQuery(queryKey, syncState = true) { + const preset = getQueryPreset(queryKey); + appState.query = queryKey; + appState.selection = preset.selection; + if (syncState) { + appState.activeState = preset.settledState; + } + renderAll(); +} + +function startRunPreview() { + clearRunTimer(); + appState.activeState = "running"; + setActiveTab("results"); + renderState(); + appState.runTimer = setTimeout(() => { + appState.runTimer = null; + appState.activeState = getQueryPreset().settledState; + renderState(); + }, 1100); +} + +connectionCards.forEach((card) => { + card.addEventListener("click", () => { + clearRunTimer(); + connectionCards.forEach((item) => item.classList.remove("is-active")); + card.classList.add("is-active"); + appState.connection = card.dataset.connection; + appState.query = "success"; + appState.selection = getWorkspaceFixture().defaultSelection; + appState.activeState = getQueryPreset().settledState; + schemaSearch.value = ""; + setActiveTab("results"); + renderAll(); + statusMessage.textContent = `${card.dataset.connection} loaded. Catalog, draft labels, and inspector now reflect the active target.`; + }); +}); + +schemaTree.addEventListener("click", (event) => { + const item = event.target.closest(".tree-item"); + + if (!item) { + return; + } + + syncSelection(item.dataset.selection); + statusMessage.textContent = `Selection changed to ${item.dataset.selection}. Inspector and workspace stay aligned.`; +}); + +toolbarTabsContainer.addEventListener("click", (event) => { + const tab = event.target.closest(".toolbar-tab"); + + if (!tab) { + return; + } + + clearRunTimer(); + setActiveTab("results"); + setQuery(tab.dataset.query, true); }); bottomTabs.forEach((tab) => { tab.addEventListener("click", () => { - bottomTabs.forEach((item) => item.classList.remove("is-active")); - tabContents.forEach((item) => item.classList.remove("is-active")); - - tab.classList.add("is-active"); - document - .querySelector(`[data-content="${tab.dataset.tab}"]`) - .classList.add("is-active"); + setActiveTab(tab.dataset.tab); }); }); + +runQueryButton.addEventListener("click", () => { + startRunPreview(); +}); + +cycleStateButton.addEventListener("click", () => { + clearRunTimer(); + const currentIndex = STATE_ORDER.indexOf(appState.activeState); + const nextState = STATE_ORDER[(currentIndex + 1) % STATE_ORDER.length]; + appState.activeState = nextState; + renderState(); +}); + +function activateExport(format) { + clearRunTimer(); + + if (appState.activeState === "running") { + renderState(); + statusMessage.textContent = `${format} export is locked until the running query finishes.`; + return; + } + + if (appState.activeState === "error") { + renderState(); + statusMessage.textContent = `${format} export is blocked because the current query failed.`; + return; + } + + setActiveTab("export"); + + if (appState.activeState === "empty") { + renderState(); + statusMessage.textContent = `${format} export preview open. Current result is empty, so no file would be written.`; + return; + } + + appState.activeState = "exported"; + renderState(); + statusMessage.textContent = `${format} export preview active. No file is written in this prototype.`; +} + +exportCurrentButton.addEventListener("click", () => activateExport("Workspace")); +exportCsvButton.addEventListener("click", () => activateExport("CSV")); +exportJsonButton.addEventListener("click", () => activateExport("JSON")); + +themeToggle.addEventListener("click", () => { + document.documentElement.dataset.theme = + document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + renderThemeLabel(); +}); + +commandButton.addEventListener("click", () => { + if (commandPalette.hidden) { + openCommandPalette(); + return; + } + + closeCommandPalette(); +}); + +commandBackdrop.addEventListener("click", closeCommandPalette); +commandCloseButton.addEventListener("click", closeCommandPalette); + +commandStateButtons.forEach((button) => { + button.addEventListener("click", () => { + clearRunTimer(); + appState.activeState = button.dataset.commandState; + renderState(); + closeCommandPalette(); + }); +}); + +commandConnectionButtons.forEach((button) => { + button.addEventListener("click", () => { + const card = document.querySelector(`.connection-card[data-connection="${button.dataset.commandConnection}"]`); + card?.click(); + closeCommandPalette(); + }); +}); + +commandActionButtons.forEach((button) => { + button.addEventListener("click", () => { + closeCommandPalette(); + + if (button.dataset.commandAction === "run") { + startRunPreview(); + return; + } + + if (button.dataset.commandAction === "search") { + schemaSearch.focus(); + statusMessage.textContent = "Schema search focused."; + return; + } + + if (button.dataset.commandAction === "theme") { + themeToggle.click(); + } + }); +}); + +schemaSearch.addEventListener("input", () => { + applySchemaFilter(); +}); + +clearSearch.addEventListener("click", () => { + schemaSearch.value = ""; + applySchemaFilter(); + statusMessage.textContent = "Schema search cleared."; +}); + +document.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + + if (commandPalette.hidden) { + openCommandPalette(); + return; + } + + closeCommandPalette(); + return; + } + + if (!commandPalette.hidden && event.key === "Escape") { + event.preventDefault(); + closeCommandPalette(); + statusMessage.textContent = "Quick actions closed."; + return; + } + + if (event.target === schemaSearch) { + return; + } + + if (event.key === "/") { + event.preventDefault(); + schemaSearch.focus(); + statusMessage.textContent = "Schema search focused."; + return; + } + + if (event.key.toLowerCase() === "t") { + event.preventDefault(); + themeToggle.click(); + statusMessage.textContent = "Theme toggled."; + return; + } + + if (event.key.toLowerCase() === "r") { + event.preventDefault(); + startRunPreview(); + return; + } + + const hotkeyMap = { + 1: "running", + 2: "success", + 3: "empty", + 4: "error", + 5: "exported", + }; + + if (hotkeyMap[event.key]) { + event.preventDefault(); + clearRunTimer(); + appState.activeState = hotkeyMap[event.key]; + renderState(); + } +}); + +renderAll(); diff --git a/gui/prototype/index.html b/gui/prototype/index.html index 02cbfbb..f6addb4 100644 --- a/gui/prototype/index.html +++ b/gui/prototype/index.html @@ -1,5 +1,5 @@ - + @@ -13,19 +13,49 @@
DB

dbtool future desktop

-

Query Workspace Foundation

+

Operator Workspace Foundation

+

+ 面向数据库操作者的安静型工作台原型,聚焦 connect → inspect → query → export。 +

+
- - + +
- pg-prod-readonly +
+ Active Target + pg-prod-readonly +
+
+
+ Workspace + Production Read Audit +

只读 PostgreSQL 连接,聚焦近 7 天订单审计。

+
+
+ Selection + public.orders +

Schema browser 与 inspector 指向同一对象。

+
+
+ Last Run + Success · 50 rows +

182 ms,结果可导出。

+
+
+ Export + /tmp/orders-last-7-days.csv +

当前展示默认导出目标,不执行真实写盘。

+
+
+
-
- - -
+
+
- PostgreSQL + PostgreSQL + - + +
+
+ +
+
+ Target + pg-prod-readonly +
+
+ Mode + Read-only +
+
+ Selection + public.orders +
+
+ Last validated + 2 minutes ago
-
+
1 2 3 @@ -127,18 +204,10 @@ 5 6
-
select
-  id,
-  customer_id,
-  status,
-  created_at
-from public.orders
-where created_at > now() - interval '7 days'
-order by created_at desc
-limit 50;
+
-
+

Execution

Last run succeeded @@ -153,6 +222,10 @@ limit 50; Rows 50
+
+ State + Success +
@@ -162,81 +235,75 @@ limit 50; +
+
+ 50 rows · sorted by created_at desc + 结果区保留紧邻执行反馈的上下文,不让表格单独承担状态信息。 +
${column}
${tableMessage}
${cell}
- - - - - - + - - - - - - - - - - - - - - - - - - - - +
idcustomer_idstatuscreated_at
8ec1...7cb3...paid2026-03-25 13:55:01 UTC
80f8...5d10...processing2026-03-25 13:44:15 UTC
5aa2...9ab1...failed2026-03-25 13:12:48 UTC
-
-
- orders audit.sql - Success · 182 ms · 50 rows -
-
- empty result.sql - Success · 95 ms · 0 rows -
-
- bad syntax.sql - Error · syntax error near `form` -
-
+

Export Status

- Ready to export the current result set -

`/tmp/orders-last-7-days.csv` is available as the default target.

+ Ready to export the current result set +

`/tmp/orders-last-7-days.csv` is available as the default target.

- - + +
- No blocking problem -

When a query fails, this area should show the error summary, SQL fragment, and next action.

+ No blocking problem +

When a query fails, this area should show the error summary, SQL fragment, and next action.

+
+
+ +
+
+
+

Contract Snapshot

+ Query success envelope preview +

展示当前界面状态如何映射到共享结构化契约;`running` 仍属于前端暂态。

+
+
+ +
+ Ready for review. Use 1–5 to preview states, `R` to simulate run, `T` to switch theme, `/` to focus search. +
+ 1 Running + 2 Success + 3 Empty + 4 Error + 5 Exported + R Run + T Theme + / Search + ⌘/Ctrl+K Actions +
+
+ + diff --git a/gui/prototype/styles.css b/gui/prototype/styles.css index 1b9498c..94484ca 100644 --- a/gui/prototype/styles.css +++ b/gui/prototype/styles.css @@ -1,20 +1,44 @@ -:root { +html[data-theme="dark"] { color-scheme: dark; - --bg: #0f1115; - --panel: #141821; - --panel-muted: #10141c; - --panel-strong: #1a202b; - --border: #273041; - --text: #e6e8ee; - --muted: #9da7b8; - --accent: #7dd3fc; - --accent-soft: rgba(125, 211, 252, 0.12); - --success: #7dd3a0; - --warning: #f5c26b; - --danger: #f09c9c; - --shadow: 0 18px 40px rgba(0, 0, 0, 0.22); - --radius: 14px; - --radius-sm: 10px; + --bg: #11141a; + --bg-elevated: #161b22; + --panel: #171d25; + --panel-muted: #11161d; + --panel-strong: #202835; + --border: #2a3341; + --border-strong: #3a4759; + --text: #e6ebf2; + --muted: #9aa6b6; + --accent: #8bb8ff; + --accent-soft: rgba(139, 184, 255, 0.14); + --success: #7bc59a; + --warning: #d7b26d; + --danger: #e09a9a; + --shadow: 0 18px 44px rgba(0, 0, 0, 0.2); +} + +html[data-theme="light"] { + color-scheme: light; + --bg: #eef2f6; + --bg-elevated: #f6f8fb; + --panel: #fbfcfe; + --panel-muted: #f3f6fa; + --panel-strong: #e9eef5; + --border: #d7dde7; + --border-strong: #c2cddd; + --text: #162031; + --muted: #5f6f84; + --accent: #3667d6; + --accent-soft: rgba(54, 103, 214, 0.1); + --success: #237a4b; + --warning: #946400; + --danger: #af3a3a; + --shadow: 0 16px 36px rgba(18, 30, 46, 0.08); +} + +:root { + --radius: 16px; + --radius-sm: 12px; --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --mono: "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace; } @@ -26,13 +50,15 @@ body { margin: 0; min-height: 100vh; - background: - radial-gradient(circle at top, rgba(125, 211, 252, 0.07), transparent 25%), - linear-gradient(180deg, #0d1016 0%, #0f1115 100%); + background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg) 100%); color: var(--text); font-family: var(--font); } +body.has-modal { + overflow: hidden; +} + button, input, textarea, @@ -42,21 +68,21 @@ select { .app-shell { min-height: 100vh; - padding: 18px; + padding: 20px; } .topbar { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 16px; - padding: 18px 22px; + gap: 18px; + padding: 20px 24px; margin-bottom: 16px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(20, 24, 33, 0.82); + background: rgba(255, 255, 255, 0.04); box-shadow: var(--shadow); - backdrop-filter: blur(12px); + backdrop-filter: blur(10px); } .topbar__brand, @@ -67,7 +93,10 @@ select { .toolbar-actions, .bottom-tabs, .export-box, -.export-actions { +.export-actions, +.connection-card__head, +.connection-card__meta, +.status-shortcuts { display: flex; align-items: center; gap: 12px; @@ -78,20 +107,34 @@ select { .panel-section h2 { margin: 0; font-size: 16px; + letter-spacing: -0.01em; +} + +.topbar__copy { + margin: 8px 0 0; + max-width: 620px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; } .brand-mark { display: grid; place-items: center; - width: 40px; - height: 40px; - border: 1px solid rgba(125, 211, 252, 0.3); + width: 44px; + height: 44px; + border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border)); border-radius: 12px; background: var(--accent-soft); color: var(--accent); font-weight: 700; } +.topbar__context { + justify-content: flex-end; + flex-wrap: wrap; +} + .eyebrow { margin: 0 0 4px; color: var(--muted); @@ -100,17 +143,47 @@ select { text-transform: uppercase; } +.workspace-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.summary-card { + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); +} + +.summary-card strong { + display: block; + margin-top: 6px; + font-size: 15px; +} + +.summary-card p, +.summary-card__label, +.pill-label, +.result-summary span { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; +} + .workspace { display: grid; grid-template-columns: 280px minmax(640px, 1fr) 280px; gap: 16px; - min-height: calc(100vh - 124px); + min-height: calc(100vh - 222px); } .panel { border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(20, 24, 33, 0.9); + background: var(--panel); box-shadow: var(--shadow); overflow: hidden; } @@ -123,13 +196,15 @@ select { .panel--main { display: grid; - grid-template-rows: auto minmax(240px, 1fr) auto minmax(260px, 0.9fr); + grid-template-rows: auto auto minmax(260px, 1fr) auto minmax(280px, 0.92fr) auto; } .panel-section, .editor-toolbar, +.context-strip, .execution-banner, -.bottom-panel { +.bottom-panel, +.status-bar { padding: 18px; } @@ -146,9 +221,11 @@ select { .search-field, .info-card, .log-row, -.problem-box { +.problem-box, +.summary-card, +.result-summary { border: 1px solid var(--border); - border-radius: 999px; + border-radius: 14px; } .ghost-button, @@ -156,6 +233,7 @@ select { .toolbar-tab, .bottom-tab { cursor: pointer; + transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease; } .ghost-button, @@ -167,6 +245,21 @@ select { color: var(--muted); } +.ghost-button:hover, +.action-button:hover, +.toolbar-tab:hover, +.bottom-tab:hover, +.connection-card:hover, +.tree-item:hover { + border-color: var(--border-strong); +} + +.ghost-button:disabled, +.action-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .ghost-button--small { padding: 6px 10px; } @@ -178,17 +271,17 @@ select { } .action-button--primary { - border-color: rgba(125, 211, 252, 0.28); - background: rgba(125, 211, 252, 0.16); - color: #dff6ff; + border-color: color-mix(in srgb, var(--accent) 32%, var(--border)); + background: var(--accent-soft); + color: var(--text); } .connection-pill, .pill { display: inline-flex; align-items: center; - gap: 8px; - padding: 7px 11px; + gap: 10px; + padding: 9px 12px; background: var(--panel-muted); } @@ -221,10 +314,8 @@ select { } .connection-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + display: grid; + gap: 10px; width: 100%; padding: 14px; border: 1px solid var(--border); @@ -245,35 +336,108 @@ select { color: var(--muted); } -.connection-card.is-active { - border-color: rgba(125, 211, 252, 0.4); - background: rgba(125, 211, 252, 0.08); +.connection-card__head, +.connection-card__meta { + justify-content: space-between; } -.tree details { +.connection-card__meta { + color: var(--muted); + font-size: 12px; +} + +.connection-card.is-active { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + background: var(--accent-soft); +} + +.search-field { + display: grid; + gap: 8px; + border-radius: var(--radius-sm); +} + +.search-field input { + width: 100%; + padding: 0; + border: 0; + outline: none; + background: transparent; + color: var(--text); +} + +.tree-group { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-muted); } +.tree-group[hidden], +.tree-item[hidden] { + display: none; +} + .tree summary { cursor: pointer; font-weight: 600; } -.tree ul { - margin: 10px 0 0 18px; - padding: 0; +.tree-item { + display: grid; + gap: 4px; + width: 100%; + margin-top: 10px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: transparent; + color: inherit; + text-align: left; +} + +.tree-item span { color: var(--muted); + font-size: 12px; +} + +.tree-item.is-selected { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + background: var(--accent-soft); +} + +.context-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--panel-muted); +} + +.context-strip div { + min-width: 0; +} + +.context-label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 12px; +} + +.context-strip strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .editor { display: grid; grid-template-columns: 52px 1fr; - border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); - background: #10141b; + background: var(--panel-muted); } .editor-gutter, @@ -286,9 +450,9 @@ select { } .editor-gutter { - color: #697386; + color: var(--muted); border-right: 1px solid var(--border); - background: #0d1016; + background: color-mix(in srgb, var(--panel-muted) 82%, black 18%); } .editor-gutter span { @@ -296,7 +460,7 @@ select { } .editor-code { - color: #d7e2f0; + color: var(--text); overflow: auto; } @@ -305,16 +469,29 @@ select { align-items: center; justify-content: space-between; gap: 18px; - background: linear-gradient(90deg, rgba(125, 211, 160, 0.12), rgba(125, 211, 252, 0.04)); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--success) 10%, var(--panel)); +} + +.execution-banner.is-running { + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); +} + +.execution-banner.is-empty { + background: color-mix(in srgb, var(--warning) 10%, var(--panel)); } .execution-banner.is-error { - background: linear-gradient(90deg, rgba(240, 156, 156, 0.16), rgba(240, 156, 156, 0.06)); + background: color-mix(in srgb, var(--danger) 12%, var(--panel)); +} + +.execution-banner.is-exported { + background: color-mix(in srgb, var(--accent) 12%, var(--panel)); } .banner-metrics { display: grid; - grid-template-columns: repeat(2, minmax(88px, 1fr)); + grid-template-columns: repeat(3, minmax(88px, 1fr)); gap: 12px; } @@ -342,11 +519,25 @@ select { gap: 16px; } +.result-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding: 12px 14px; + background: var(--panel-muted); +} + +.result-summary strong { + font-size: 13px; +} + .toolbar-tab.is-active, .bottom-tab.is-active { - border-color: rgba(125, 211, 252, 0.3); - background: rgba(125, 211, 252, 0.14); - color: #e4f7ff; + border-color: color-mix(in srgb, var(--accent) 32%, var(--border)); + background: var(--accent-soft); + color: var(--text); } .tab-content { @@ -380,14 +571,34 @@ td { th { color: var(--muted); font-weight: 600; - background: #121722; + background: var(--panel-strong); } .log-row, .export-box, -.problem-box { +.problem-box, +.contract-card { padding: 16px; background: var(--panel-muted); + border-radius: var(--radius-sm); +} + +.contract-card { + display: grid; + gap: 14px; +} + +.contract-preview { + margin: 0; + padding: 16px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel-muted) 74%, black 26%); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; } .focus-list { @@ -398,17 +609,128 @@ th { margin-top: 10px; } -@media (max-width: 1320px) { +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 12px; +} + +.status-shortcuts { + flex-wrap: wrap; + justify-content: flex-end; +} + +.command-palette { + position: fixed; + inset: 0; + z-index: 40; +} + +.command-palette__backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(9, 12, 18, 0.48); + backdrop-filter: blur(8px); +} + +.command-dialog { + position: relative; + z-index: 1; + width: min(920px, calc(100vw - 32px)); + margin: 56px auto; + padding: 20px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); +} + +.command-dialog__head, +.command-grid, +.command-list { + display: grid; + gap: 14px; +} + +.command-dialog__head { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + margin-bottom: 16px; +} + +.command-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.command-section { + min-width: 0; +} + +.command-item { + display: grid; + gap: 6px; + width: 100%; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-muted); + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease; +} + +.command-item:hover, +.command-item:focus-visible { + border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); + background: var(--accent-soft); + outline: none; +} + +.command-item span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +@media (max-width: 1440px) { + .workspace-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .workspace { grid-template-columns: 250px minmax(520px, 1fr) 250px; } } @media (max-width: 1100px) { + .topbar, + .status-bar, + .result-summary, + .context-strip, + .workspace-summary { + grid-template-columns: 1fr; + } + + .topbar, + .status-bar, + .result-summary { + display: grid; + } + .workspace { grid-template-columns: 1fr; } + .command-grid { + grid-template-columns: 1fr; + } + .panel--main { order: 1; } diff --git a/scripts/qa/run-failure-path-evidence-sidecar.sh b/scripts/qa/run-failure-path-evidence-sidecar.sh new file mode 100755 index 0000000..7b5c1fd --- /dev/null +++ b/scripts/qa/run-failure-path-evidence-sidecar.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: scripts/qa/run-failure-path-evidence-sidecar.sh [output-dir]" >&2 + exit 2 +fi + +binary_path="$1" +output_dir="${2:-./tmp/failure-path-evidence}" + +if [ ! -x "$binary_path" ]; then + echo "error: binary is not executable: $binary_path" >&2 + exit 3 +fi + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +sidecar_image="${QA_SIDECAR_IMAGE:-paperclip-local-db-codex}" +docker_network="${QA_DOCKER_NETWORK:-dbtool-cli-v1_default}" +postgres_container="${QA_POSTGRES_CONTAINER:-dbtool-cli-v1-postgres-1}" +mysql_container="${QA_MYSQL_CONTAINER:-dbtool-cli-v1-mysql-1}" +postgres_user="${QA_POSTGRES_USER:-dbtool}" +postgres_db="${QA_POSTGRES_DB:-dbtool_demo}" +mysql_root_password="${QA_MYSQL_ROOT_PASSWORD:-dbtoolroot}" +sidecar_root="/tmp/work" +sidecar_output_dir="$sidecar_root/out" +host_label="${QA_HOST_LABEL:-$(hostname 2>/dev/null || echo unknown-host)}" +binary_label="${QA_BINARY_PATH_LABEL:-$binary_path}" +binary_version="${QA_BINARY_VERSION:-$("$binary_path" --version 2>/dev/null | head -n 1 || echo unknown-version)}" +commit_sha="$(git -C "$root_dir" rev-parse HEAD 2>/dev/null || echo unknown-commit)" +branch_name="$(git -C "$root_dir" branch --show-current 2>/dev/null || echo unknown-branch)" + +cid="" +cleanup() { + if [ -n "$cid" ]; then + docker rm -f "$cid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +mkdir -p "$output_dir" + +docker image inspect "$sidecar_image" >/dev/null + +docker exec -i "$postgres_container" \ + psql -v ON_ERROR_STOP=1 -U "$postgres_user" -d "$postgres_db" \ + < "$root_dir/examples/fixtures/postgres/init.sql" >/dev/null + +docker exec -i "$mysql_container" \ + mysql --default-character-set=utf8mb4 -uroot "-p$mysql_root_password" \ + < "$root_dir/examples/fixtures/mysql/init.sql" >/dev/null + +cid="$(docker create --network "$docker_network" "$sidecar_image" sleep 300)" +docker start "$cid" >/dev/null + +docker exec "$cid" mkdir -p \ + "$sidecar_root/examples/sql/postgres" \ + "$sidecar_root/examples/sql/mysql" \ + "$sidecar_root/scripts/qa" + +docker cp "$binary_path" "$cid":"$sidecar_root/dbtool" +docker cp "$root_dir/examples/sql/postgres/failing_query.sql" \ + "$cid":"$sidecar_root/examples/sql/postgres/failing_query.sql" +docker cp "$root_dir/examples/sql/mysql/failing_query.sql" \ + "$cid":"$sidecar_root/examples/sql/mysql/failing_query.sql" +docker cp "$root_dir/scripts/qa/run-failure-path-evidence.sh" \ + "$cid":"$sidecar_root/scripts/qa/run-failure-path-evidence.sh" + +docker exec "$cid" chmod +x \ + "$sidecar_root/dbtool" \ + "$sidecar_root/scripts/qa/run-failure-path-evidence.sh" + +docker exec \ + -e QA_OPERATOR="${QA_OPERATOR:-qa-engineer}" \ + -e QA_HOST_LABEL="$host_label" \ + -e QA_BINARY_PATH_LABEL="$binary_label" \ + -e QA_BINARY_VERSION="$binary_version" \ + -e QA_EXECUTION_PATH=sidecar \ + -e QA_COMMIT_SHA="$commit_sha" \ + -e QA_BRANCH_NAME="$branch_name" \ + -e DBTOOL_POSTGRES_HOST=postgres \ + -e DBTOOL_POSTGRES_PORT=5432 \ + -e DBTOOL_POSTGRES_NET_PORT=59999 \ + -e DBTOOL_MYSQL_HOST=mysql \ + -e DBTOOL_MYSQL_PORT=3306 \ + -e DBTOOL_MYSQL_NET_PORT=59998 \ + "$cid" \ + bash -lc \ + "cd '$sidecar_root' && ./scripts/qa/run-failure-path-evidence.sh ./dbtool all '$sidecar_output_dir'" + +rm -rf "$output_dir" +mkdir -p "$output_dir" +docker cp "$cid":"$sidecar_output_dir"/. "$output_dir"/ + +echo "sidecar evidence written to: $output_dir" diff --git a/scripts/qa/run-failure-path-evidence.sh b/scripts/qa/run-failure-path-evidence.sh new file mode 100755 index 0000000..22639d8 --- /dev/null +++ b/scripts/qa/run-failure-path-evidence.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then + echo "usage: scripts/qa/run-failure-path-evidence.sh [postgres|mysql|all] [output-dir]" >&2 + exit 2 +fi + +binary_path="$1" +scope="${2:-all}" +output_dir="${3:-./tmp/failure-path-evidence}" + +if [ ! -x "$binary_path" ]; then + echo "error: binary is not executable: $binary_path" >&2 + exit 3 +fi + +case "$scope" in + postgres|mysql|all) ;; + *) + echo "error: scope must be one of: postgres, mysql, all" >&2 + exit 4 + ;; +esac + +mkdir -p "$output_dir" + +operator="${QA_OPERATOR:-qa-engineer}" +host_name="${QA_HOST_LABEL:-$(hostname 2>/dev/null || echo unknown-host)}" +utc_now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +binary_label="${QA_BINARY_PATH_LABEL:-$binary_path}" +binary_version="${QA_BINARY_VERSION:-$("$binary_path" --version 2>/dev/null | head -n 1 || echo unknown-version)}" +execution_path="${QA_EXECUTION_PATH:-host}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +commit_sha="${QA_COMMIT_SHA:-$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || echo unknown-commit)}" +branch_name="${QA_BRANCH_NAME:-$(git -C "$repo_root" branch --show-current 2>/dev/null || echo unknown-branch)}" +postgres_host="${DBTOOL_POSTGRES_HOST:-127.0.0.1}" +postgres_port="${DBTOOL_POSTGRES_PORT:-55432}" +mysql_host="${DBTOOL_MYSQL_HOST:-127.0.0.1}" +mysql_port="${DBTOOL_MYSQL_PORT:-53306}" +postgres_net_port="${DBTOOL_POSTGRES_NET_PORT:-59999}" +mysql_net_port="${DBTOOL_MYSQL_NET_PORT:-59998}" + +has_missing_traceability_value() { + local value="$1" + local placeholder="${2:-}" + + if [ -z "$value" ]; then + return 0 + fi + + if [ -n "$placeholder" ] && [ "$value" = "$placeholder" ]; then + return 0 + fi + + return 1 +} + +contains_secret_leakage() { + local raw_output_file="$1" + local password_value="$2" + local leakage_mode="${3:-contextual}" + + [ -z "$password_value" ] && return 1 + + if [ "$leakage_mode" = "exact" ] && grep -Fq "$password_value" "$raw_output_file"; then + return 0 + fi + + local patterns=( + "\"password\":\"$password_value\"" + "\"password\": \"$password_value\"" + "password=$password_value" + "password: $password_value" + "password $password_value" + "DBTOOL_PASSWORD=$password_value" + ":$password_value@" + ) + + local pattern + for pattern in "${patterns[@]}"; do + if grep -Fq "$pattern" "$raw_output_file"; then + return 0 + fi + done + + return 1 +} + +extract_json_string_field() { + local raw_output_file="$1" + local field_name="$2" + + sed -nE "s/.*\"${field_name}\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/p" "$raw_output_file" \ + | head -n 1 +} + +run_scenario() { + local scenario_id="$1" + local driver="$2" + local expected_kind="$3" + local password_value="$4" + local leakage_mode="$5" + shift 5 + local args=("$@") + + local raw_output_file="$output_dir/${scenario_id}.out" + local markdown_file="$output_dir/${scenario_id}.md" + + ( + export DBTOOL_PASSWORD="$password_value" + set +e + "$binary_path" "${args[@]}" >"$raw_output_file" 2>&1 + local exit_code=$? + set -e + + local output_excerpt + output_excerpt="$(sed -n '1,120p' "$raw_output_file")" + + local status_value + status_value="$(extract_json_string_field "$raw_output_file" "status")" + local state_value + state_value="$(extract_json_string_field "$raw_output_file" "state")" + local kind_value + kind_value="$(extract_json_string_field "$raw_output_file" "kind")" + local key_output_summary="status=${status_value:-missing}, state=${state_value:-missing}, error.kind=${kind_value:-missing}" + + local leakage_status="not-detected" + if contains_secret_leakage "$raw_output_file" "$password_value" "$leakage_mode"; then + leakage_status="detected" + fi + + local command_text="$binary_label ${args[*]}" + local traceability_gaps=() + if has_missing_traceability_value "$utc_now"; then + traceability_gaps+=("date") + fi + if has_missing_traceability_value "$operator"; then + traceability_gaps+=("operator") + fi + if has_missing_traceability_value "$host_name" "unknown-host"; then + traceability_gaps+=("host") + fi + if has_missing_traceability_value "$binary_label"; then + traceability_gaps+=("binary_path") + fi + if has_missing_traceability_value "$binary_version" "unknown-version"; then + traceability_gaps+=("binary_version") + fi + if has_missing_traceability_value "$execution_path"; then + traceability_gaps+=("execution_path") + fi + if has_missing_traceability_value "$commit_sha" "unknown-commit"; then + traceability_gaps+=("commit_sha") + fi + if has_missing_traceability_value "$branch_name" "unknown-branch"; then + traceability_gaps+=("branch") + fi + if has_missing_traceability_value "$driver"; then + traceability_gaps+=("driver") + fi + if has_missing_traceability_value "$scenario_id"; then + traceability_gaps+=("scenario_id") + fi + if has_missing_traceability_value "$command_text"; then + traceability_gaps+=("command") + fi + if has_missing_traceability_value "$key_output_summary"; then + traceability_gaps+=("key_output") + fi + + local traceability_status="complete" + local traceability_summary="none" + if [ "${#traceability_gaps[@]}" -gt 0 ]; then + traceability_status="incomplete" + traceability_summary="$(printf '%s, ' "${traceability_gaps[@]}")" + traceability_summary="${traceability_summary%, }" + fi + + local verdict="fail" + if [ "$exit_code" -ne 0 ] \ + && grep -Eq '"status"[[:space:]]*:[[:space:]]*"error"' "$raw_output_file" \ + && grep -Eq '"state"[[:space:]]*:[[:space:]]*"error"' "$raw_output_file" \ + && grep -Eq "\"kind\"[[:space:]]*:[[:space:]]*\"${expected_kind}\"" "$raw_output_file" \ + && [ "$leakage_status" = "not-detected" ] \ + && [ "$traceability_status" = "complete" ]; then + verdict="pass" + fi + + cat >"$markdown_file" < ${markdown_file} (${verdict})" + ) +} + +if [ "$scope" = "postgres" ] || [ "$scope" = "all" ]; then + run_scenario \ + "PG-CONN-AUTH-001" \ + "postgres" \ + "authentication" \ + "wrong-password" \ + "exact" \ + connect --driver postgres --host "$postgres_host" --port "$postgres_port" --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json + + run_scenario \ + "PG-CONN-NET-001" \ + "postgres" \ + "connection" \ + "dbtool" \ + "contextual" \ + connect --driver postgres --host "$postgres_host" --port "$postgres_net_port" --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json + + run_scenario \ + "PG-QUERY-SQL-001" \ + "postgres" \ + "query" \ + "dbtool" \ + "contextual" \ + query --driver postgres --host "$postgres_host" --port "$postgres_port" --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/postgres/failing_query.sql --result-format json +fi + +if [ "$scope" = "mysql" ] || [ "$scope" = "all" ]; then + run_scenario \ + "MY-CONN-AUTH-001" \ + "mysql" \ + "authentication" \ + "wrong-password" \ + "exact" \ + connect --driver mysql --host "$mysql_host" --port "$mysql_port" --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json + + run_scenario \ + "MY-CONN-NET-001" \ + "mysql" \ + "connection" \ + "dbtool" \ + "contextual" \ + connect --driver mysql --host "$mysql_host" --port "$mysql_net_port" --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --result-format json + + run_scenario \ + "MY-QUERY-SQL-001" \ + "mysql" \ + "query" \ + "dbtool" \ + "contextual" \ + query --driver mysql --host "$mysql_host" --port "$mysql_port" --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/mysql/failing_query.sql --result-format json +fi + +echo "evidence written to: $output_dir" diff --git a/scripts/qa/test-failure-path-evidence-sidecar.sh b/scripts/qa/test-failure-path-evidence-sidecar.sh new file mode 100755 index 0000000..3400e3b --- /dev/null +++ b/scripts/qa/test-failure-path-evidence-sidecar.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp_dir="$(mktemp -d)" +state_dir="$tmp_dir/state" +fake_bin_dir="$tmp_dir/bin" +output_dir="$tmp_dir/out" +mkdir -p "$state_dir/container_fs" "$fake_bin_dir" "$output_dir" + +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +cat >"$tmp_dir/fake-dbtool" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "--version" ]; then + echo "fake-dbtool 0.1.0" + exit 0 +fi +printf '{"status":"error","state":"error","error":{"kind":"authentication"}}\n' +exit 2 +EOF +chmod +x "$tmp_dir/fake-dbtool" + +cat >"$fake_bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +state_dir="${MOCK_DOCKER_STATE_DIR:?}" +container_fs="$state_dir/container_fs" + +cmd="${1:?}" +shift + +copy_into_container() { + local src="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" +} + +copy_from_container() { + local src="$1" + local dest="$2" + mkdir -p "$dest" + cp -R "$src"/. "$dest"/ +} + +case "$cmd" in + image) + [ "${1:-}" = "inspect" ] || exit 90 + exit 0 + ;; + create) + echo "mock-sidecar-id" + exit 0 + ;; + start) + exit 0 + ;; + rm) + exit 0 + ;; + cp) + src="${1:?}" + dest="${2:?}" + if [[ "$src" == *:* ]]; then + container_src="${src#*:}" + container_src="${container_src%/.}" + copy_from_container "$container_fs$container_src" "$dest" + else + container_dest="${dest#*:}" + copy_into_container "$src" "$container_fs$container_dest" + fi + exit 0 + ;; + exec) + env_pairs=() + while [ "${1:-}" = "-e" ]; do + env_pairs+=("${2:?}") + shift 2 + done + + if [ "${1:-}" = "-i" ]; then + shift + shift + cat >/dev/null + exit 0 + fi + + target="${1:?}" + shift + + case "${1:-}" in + mkdir) + shift + [ "${1:-}" = "-p" ] || exit 91 + shift + for path in "$@"; do + mkdir -p "$container_fs$path" + done + exit 0 + ;; + chmod) + exit 0 + ;; + bash) + [ "${2:-}" = "-lc" ] || exit 92 + for pair in "${env_pairs[@]}"; do + export "$pair" + done + out_dir="$container_fs/tmp/work/out" + mkdir -p "$out_dir" + cat >"$out_dir/PG-CONN-AUTH-001.md" <&2 + exit 93 + ;; + esac + ;; + *) + echo "unsupported docker command: $cmd" >&2 + exit 94 + ;; +esac +EOF +chmod +x "$fake_bin_dir/docker" + +export PATH="$fake_bin_dir:$PATH" +export MOCK_DOCKER_STATE_DIR="$state_dir" +export QA_HOST_LABEL="runner-host-01" +export QA_BINARY_PATH_LABEL="./target/release/dbtool" +export QA_BINARY_VERSION="dbtool 0.1.0" + +"$root_dir/scripts/qa/run-failure-path-evidence-sidecar.sh" \ + "$tmp_dir/fake-dbtool" \ + "$output_dir" >/dev/null + +evidence_file="$output_dir/PG-CONN-AUTH-001.md" +[ -f "$evidence_file" ] || { + echo "expected evidence file not copied back: $evidence_file" >&2 + exit 95 +} + +grep -Fq 'Host: `runner-host-01`' "$evidence_file" +grep -Fq 'Execution Path: `sidecar`' "$evidence_file" +grep -Fq 'Binary Path: `./target/release/dbtool`' "$evidence_file" +grep -Fq 'Binary Version: `dbtool 0.1.0`' "$evidence_file" +grep -Fq 'Commit SHA: `' "$evidence_file" +grep -Fq 'Branch: `' "$evidence_file" +grep -Fq 'Result: `pass`' "$evidence_file" + +echo "sidecar helper fixture passed" diff --git a/scripts/tui/demo-stack.sh b/scripts/tui/demo-stack.sh new file mode 100755 index 0000000..c4323ba --- /dev/null +++ b/scripts/tui/demo-stack.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.demo.yml}" +POSTGRES_CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-dbtool-cli-v1-postgres-1}" +MYSQL_CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-dbtool-cli-v1-mysql-1}" +DEMO_NETWORK_NAME="${DEMO_NETWORK_NAME:-dbtool-cli-v1_default}" + +usage() { + echo "usage: scripts/tui/demo-stack.sh " >&2 + exit 2 +} + +docker_compose_available() { + docker compose version >/dev/null 2>&1 +} + +container_exists() { + local container_name="$1" + docker container inspect "$container_name" >/dev/null 2>&1 +} + +container_running() { + local container_name="$1" + [ "$(docker inspect -f '{{.State.Running}}' "$container_name" 2>/dev/null || true)" = "true" ] +} + +ensure_network() { + if ! docker network inspect "$DEMO_NETWORK_NAME" >/dev/null 2>&1; then + docker network create "$DEMO_NETWORK_NAME" >/dev/null + fi +} + +wait_for_health() { + local container_name="$1" + local max_attempts="${2:-60}" + local attempt=1 + while [ "$attempt" -le "$max_attempts" ]; do + local health + health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}' "$container_name" 2>/dev/null || true)" + if [ "$health" = "healthy" ] || [ "$health" = "running" ]; then + return 0 + fi + sleep 2 + attempt=$((attempt + 1)) + done + echo "error: container did not become healthy: $container_name" >&2 + docker logs --tail 50 "$container_name" >&2 || true + exit 1 +} + +run_postgres_container() { + if container_exists "$POSTGRES_CONTAINER_NAME"; then + if ! container_running "$POSTGRES_CONTAINER_NAME"; then + docker start "$POSTGRES_CONTAINER_NAME" >/dev/null + fi + return + fi + + ensure_network + docker run -d \ + --name "$POSTGRES_CONTAINER_NAME" \ + --network "$DEMO_NETWORK_NAME" \ + -p 55432:5432 \ + -e POSTGRES_DB=dbtool_demo \ + -e POSTGRES_USER=dbtool \ + -e POSTGRES_PASSWORD=dbtool \ + --health-cmd 'pg_isready -U dbtool -d dbtool_demo' \ + --health-interval 5s \ + --health-timeout 5s \ + --health-retries 20 \ + postgres:16 >/dev/null +} + +run_mysql_container() { + if container_exists "$MYSQL_CONTAINER_NAME"; then + if ! container_running "$MYSQL_CONTAINER_NAME"; then + docker start "$MYSQL_CONTAINER_NAME" >/dev/null + fi + return + fi + + ensure_network + docker run -d \ + --name "$MYSQL_CONTAINER_NAME" \ + --network "$DEMO_NETWORK_NAME" \ + -p 53306:3306 \ + -e MYSQL_DATABASE=dbtool_demo \ + -e MYSQL_USER=dbtool \ + -e MYSQL_PASSWORD=dbtool \ + -e MYSQL_ROOT_PASSWORD=dbtoolroot \ + --health-cmd 'mysqladmin ping -uroot -pdbtoolroot' \ + --health-interval 5s \ + --health-timeout 5s \ + --health-retries 30 \ + mysql:8.4 \ + mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci >/dev/null +} + +up_stack() { + if docker_compose_available; then + docker compose -f "$COMPOSE_FILE" up -d postgres mysql + else + run_postgres_container + run_mysql_container + fi + wait_for_health "$POSTGRES_CONTAINER_NAME" + wait_for_health "$MYSQL_CONTAINER_NAME" + echo "Demo stack is ready." +} + +down_stack() { + if docker_compose_available; then + docker compose -f "$COMPOSE_FILE" down -v + return + fi + + docker rm -f "$POSTGRES_CONTAINER_NAME" "$MYSQL_CONTAINER_NAME" >/dev/null 2>&1 || true +} + +seed_stack() { + "$ROOT_DIR/examples/scripts/bootstrap-postgres.sh" + "$ROOT_DIR/examples/scripts/bootstrap-mysql.sh" +} + +status_stack() { + docker ps --filter "name=$POSTGRES_CONTAINER_NAME" --filter "name=$MYSQL_CONTAINER_NAME" \ + --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' +} + +command="${1:-}" + +case "$command" in + up) + up_stack + ;; + down) + down_stack + ;; + seed) + seed_stack + ;; + reset) + down_stack + up_stack + seed_stack + ;; + status) + status_stack + ;; + *) + usage + ;; +esac diff --git a/scripts/tui/live-network-smoke.sh b/scripts/tui/live-network-smoke.sh new file mode 100755 index 0000000..d423f33 --- /dev/null +++ b/scripts/tui/live-network-smoke.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "usage: scripts/tui/live-network-smoke.sh [log-path]" >&2 + exit 2 +fi + +if ! command -v script >/dev/null 2>&1; then + echo "error: \`script\` command is required for TTY smoke." >&2 + exit 3 +fi + +target="$1" +binary_path="$2" +log_path="${3:-/tmp/dbtool-tui-$target-live.log}" + +if [ ! -x "$binary_path" ]; then + echo "error: binary is not executable: $binary_path" >&2 + exit 4 +fi + +case "$target" in + postgres) + down_presses=1 + endpoint='host.docker.internal:55432' + profile_name='reporting-postgres' + export_path='/tmp/dbtool-tui-reporting-postgres-account-ticket-summary-sql.csv' + activation_wait=4 + ;; + mysql) + down_presses=2 + endpoint='host.docker.internal:53306' + profile_name='orders-mysql' + export_path='/tmp/dbtool-tui-orders-mysql-account-ticket-summary-sql.csv' + activation_wait=5 + ;; + *) + echo "error: unsupported target: $target" >&2 + exit 5 + ;; +esac + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +"$ROOT_DIR/scripts/tui/demo-stack.sh" up +"$ROOT_DIR/scripts/tui/demo-stack.sh" seed + +export DBTOOL_PASSWORD="${DBTOOL_PASSWORD:-dbtool}" + +rm -f "$log_path" +{ + sleep 1 + printf '2' + if [ "$down_presses" -ge 1 ]; then + printf '\033[B' + fi + if [ "$down_presses" -ge 2 ]; then + printf '\033[B' + fi + printf '\r' + sleep "$activation_wait" + printf '\033' + printf '\r' + sleep 2 + printf 'x' + sleep 2 + printf 'q' +} | script -qec "stty rows 40 cols 120; \"$binary_path\"" "$log_path" >/dev/null + +grep -aq "$profile_name" "$log_path" +grep -aq "Validation: Connected" "$log_path" +grep -aq "rows returned in" "$log_path" +[ -f "$export_path" ] + +echo "TUI live smoke passed for $target." +echo "binary: $binary_path" +echo "endpoint: $endpoint" +echo "export: $export_path" +echo "log: $log_path" diff --git a/scripts/tui/smoke-tty.sh b/scripts/tui/smoke-tty.sh new file mode 100755 index 0000000..21cc148 --- /dev/null +++ b/scripts/tui/smoke-tty.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 4 ]; then + echo "usage: scripts/tui/smoke-tty.sh [rows] [cols] [log-path]" >&2 + exit 2 +fi + +if ! command -v script >/dev/null 2>&1; then + echo "error: \`script\` command is required for TTY smoke." >&2 + exit 3 +fi + +binary_path="$1" +rows="${2:-40}" +cols="${3:-120}" +log_path="${4:-/tmp/dbtool-tui-smoke.log}" + +if [ ! -x "$binary_path" ]; then + echo "error: binary is not executable: $binary_path" >&2 + exit 4 +fi + +command_string="$(printf 'stty rows %s cols %s; "%s"' "$rows" "$cols" "$binary_path")" + +rm -f "$log_path" +printf 'q' | script -qec "$command_string" "$log_path" >/dev/null + +echo "TTY smoke passed." +echo "binary: $binary_path" +echo "rows: $rows" +echo "cols: $cols" +echo "log: $log_path"