diff --git a/Cargo.lock b/Cargo.lock index ba0f78a..fa85d85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,11 +258,17 @@ name = "db-config" version = "0.1.0" dependencies = [ "db-core", + "serde", + "serde_json", ] [[package]] name = "db-core" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "db-drivers" diff --git a/DEVELOPING.md b/DEVELOPING.md index 68643b7..c5dac0b 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -18,6 +18,19 @@ scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.l `dbtool-tui` 需要 TTY;不要用 `cargo run -p dbtool-tui -- --help` 替代交互 smoke。详细口径见 `TUI_SMOKE_RUNBOOK.md`。 +failure-path 证据 helper / sidecar wrapper 的离线回归入口: + +```bash +scripts/qa/test-failure-path-fixtures.sh +``` + +需要缩小范围时可改跑: + +```bash +scripts/qa/test-failure-path-fixtures.sh format +scripts/qa/test-failure-path-fixtures.sh sidecar +``` + Git 仓库、branch、commit、push 与 agent 认证规范见 `GIT_WORKFLOW.md`。 如果当前 Linux 环境没有 `cc` / `ar`,改用 `zig cc` + `zig ar`。对 bundled SQLite 路径,linker 包装器需要兼容 Cargo 传入的 GNU target flag: @@ -87,6 +100,7 @@ export RUSTDOC="$(rustup which --toolchain stable rustdoc)" - `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 验证本地样例数据库闭环 +- `scripts/qa/test-failure-path-fixtures.sh` 验证 failure-path evidence helper 与 sidecar wrapper 的离线契约 - 无系统 linker 的 Linux 环境使用兼容 Cargo target flag 的 `zig cc` 包装器与 `zig ar` 验证路径 ## 发布 smoke diff --git a/FAILURE_PATH_EVIDENCE_TEMPLATE.md b/FAILURE_PATH_EVIDENCE_TEMPLATE.md index b2f6d38..9114725 100644 --- a/FAILURE_PATH_EVIDENCE_TEMPLATE.md +++ b/FAILURE_PATH_EVIDENCE_TEMPLATE.md @@ -84,6 +84,12 @@ scripts/qa/run-failure-path-evidence-sidecar.sh ./target/release/dbtool ./tmp/fa - `host` - `sidecar` +如需先确认 helper 与 sidecar wrapper 的离线夹具回归仍通过,可直接运行: + +```bash +scripts/qa/test-failure-path-fixtures.sh +``` + ## 4. 场景矩阵 | Scenario ID | Driver | Command | Failure Type | JSON Expectation | diff --git a/HOST_FAILURE_PATH_CHECKLIST.md b/HOST_FAILURE_PATH_CHECKLIST.md index 691a873..c3d216d 100644 --- a/HOST_FAILURE_PATH_CHECKLIST.md +++ b/HOST_FAILURE_PATH_CHECKLIST.md @@ -170,6 +170,12 @@ scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failur - 对 `unknown-*` 或空 traceability 字段直接判 `fail` - 在 sidecar / host 两条路径下统一保留同一组 host / binary / version / commit / branch 标签 +如需先做离线夹具回归,可直接运行: + +```bash +scripts/qa/test-failure-path-fixtures.sh +``` + 但回填前仍需人工复核 scenario、traceability 字段与输出摘要。 ## 7. 回填顺序 diff --git a/QA_RUNTIME_ENVIRONMENT.md b/QA_RUNTIME_ENVIRONMENT.md index 77b467e..e4f903b 100644 --- a/QA_RUNTIME_ENVIRONMENT.md +++ b/QA_RUNTIME_ENVIRONMENT.md @@ -106,6 +106,7 @@ ```bash export DBTOOL_PASSWORD=dbtool +export DBTOOL_POSTGRES_ADMIN_PASSWORD=dbtoolroot ``` 5. 拉起 PostgreSQL / MySQL: @@ -121,6 +122,64 @@ docker compose -f docker-compose.demo.yml up -d postgres mysql ./examples/scripts/bootstrap-mysql.sh ``` +如果本机 PostgreSQL demo 容器早于本轮权限修正创建,先清旧卷再重建: + +```bash +docker compose -f docker-compose.demo.yml down -v +docker compose -f docker-compose.demo.yml up -d postgres mysql +./examples/scripts/bootstrap-postgres.sh +./examples/scripts/bootstrap-mysql.sh +``` + +PostgreSQL demo 当前权限边界: + +- `postgres` / `dbtoolroot`:只用于 seed、restricted probe 建立与清理 +- `dbtool` / `dbtool`:QA 与 TUI/CLI live 路径使用的非超级用户 + +可先验证 `dbtool` 不再是超级用户: + +```bash +PGPASSWORD="$DBTOOL_POSTGRES_ADMIN_PASSWORD" \ +psql -h 127.0.0.1 -p 55432 -U postgres -d dbtool_demo \ + -c "select rolname, rolsuper, rolcreaterole, rolcreatedb from pg_roles where rolname='dbtool';" +``` + +PostgreSQL restricted-schema 最小复验步骤: + +```bash +PGPASSWORD="$DBTOOL_POSTGRES_ADMIN_PASSWORD" \ +psql -h 127.0.0.1 -p 55432 -U postgres -d dbtool_demo <<'SQL' +drop schema if exists restricted_probe cascade; +create schema restricted_probe; +create table restricted_probe.audit_log ( + id bigint primary key, + note text not null +); +insert into restricted_probe.audit_log values (1, 'hidden from dbtool'); +revoke all on schema restricted_probe from public; +revoke all on all tables in schema restricted_probe from public; +revoke all on schema restricted_probe from dbtool; +revoke all on all tables in schema restricted_probe from dbtool; +SQL + +export DBTOOL_PASSWORD=dbtool +cargo run -p dbtool-cli -- inspect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD +cargo run -p dbtool-cli -- inspect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --schema restricted_probe +``` + +预期: + +- root inspect 出现 `restricted_probe [restricted]` +- 显式 `--schema restricted_probe` 返回 restricted inspect error,而不是 empty tables + +清理: + +```bash +PGPASSWORD="$DBTOOL_POSTGRES_ADMIN_PASSWORD" \ +psql -h 127.0.0.1 -p 55432 -U postgres -d dbtool_demo \ + -c "drop schema if exists restricted_probe cascade;" +``` + 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. 清理环境: @@ -134,6 +193,18 @@ docker compose -f docker-compose.demo.yml down -v - PostgreSQL / MySQL happy-path 继续使用 `SMOKE_RUNBOOK.md` - PostgreSQL / MySQL failure-path 统一使用 `HOST_FAILURE_PATH_CHECKLIST.md` - 证据记录格式继续以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准 +- 在发起宿主机或 sidecar live 执行前,先跑离线夹具收敛 helper / wrapper 契约: + +```bash +scripts/qa/test-failure-path-fixtures.sh +``` + +- 若只想检查单一路径,可改跑: + +```bash +scripts/qa/test-failure-path-fixtures.sh format +scripts/qa/test-failure-path-fixtures.sh sidecar +``` ## 2026-03-28 当前容器化 runner 观察 @@ -143,7 +214,7 @@ docker compose -f docker-compose.demo.yml down -v 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` +4. 在 sidecar container 内执行 `dbtool`,默认主机名跟随 `QA_POSTGRES_CONTAINER` / `QA_MYSQL_CONTAINER`(当前默认是 `dbtool-cli-v1-postgres-1` / `dbtool-cli-v1-mysql-1`) 这条 sidecar network execution 路径在 2026-03-28 当前 heartbeat 已被直接验证,可用于继续补 PostgreSQL / MySQL failure-path 证据。 @@ -159,6 +230,7 @@ scripts/qa/run-failure-path-evidence-sidecar.sh ./target/release/dbtool ./tmp/fa - sidecar container 创建与清理 - sidecar 内 failure-path helper 执行 - 证据文件回传到本地输出目录 +- 当前 PostgreSQL sidecar 重导入默认使用 `postgres` / `dbtoolroot`;若宿主机 demo 管理员身份被改写,可改传 `QA_POSTGRES_ADMIN_USER` / `QA_POSTGRES_ADMIN_PASSWORD` ## 团队职责 diff --git a/README.md b/README.md index c45a98d..72ee958 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ 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 ``` +PostgreSQL demo 当前保留 `postgres` 作为超级用户(密码 `dbtoolroot`)供 QA seed 与创建 restricted probe;应用侧继续使用非超级用户 `dbtool`(密码 `dbtool`)。如果本地 demo 容器是旧配置初始化的,先执行 `docker compose -f docker-compose.demo.yml down -v` 或 `scripts/tui/demo-stack.sh reset` 再重建,否则 `dbtool` 仍可能保留历史超级权限。 + 如果当前 Linux 环境没有系统 `cc` / `ar`,可以改用 `zig cc` + `zig ar`。对 bundled SQLite 路径,linker 包装器需要兼容 Cargo 传入的 GNU target flag: ```bash diff --git a/TUI_ACCEPTANCE_CHECKLIST.md b/TUI_ACCEPTANCE_CHECKLIST.md index af2986c..4741d41 100644 --- a/TUI_ACCEPTANCE_CHECKLIST.md +++ b/TUI_ACCEPTANCE_CHECKLIST.md @@ -9,6 +9,7 @@ - 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。 +- 2026-03-31 当前 runner 已进一步补齐 PostgreSQL 失败连接 / 空结果 / 坏 SQL、MySQL empty schema / `28x100` 结果浏览稳定性,以及 PostgreSQL `restricted schema` live 直证;当前未收口项已收敛为 CI / packaged artifact / 非开发者复验,而不是 runner-side restricted blocker,见 [CMP-64](/CMP/issues/CMP-64)。 ## usable-v1 签收关注点 @@ -24,6 +25,12 @@ - `Esc` 在编辑模式与普通导航模式都应把用户带回更稳定、更可理解的状态。 - `[` / `]` 只在 `Query Editor` 与 `Results` 生效;文档和 QA 结论不能再写入不存在的快捷键。 +### 3. 连接管理与 secret 边界 + +- 启动前环境变量 override 仍然是 operator fallback,但不再是唯一连接管理路径;当前 TUI 已提供 in-app 的新增 / 编辑 / 删除 / 保存并激活 workflow。 +- 当前 secret handling 已区分两层:profile 文件只持久化非 secret 字段;`Session password` 只在本次 TUI 进程内复用。 +- PostgreSQL / MySQL 的 demo host override(`DBTOOL_TUI_POSTGRES_HOST/PORT`、`DBTOOL_TUI_MYSQL_HOST/PORT`)仍不能误记成普通用户主路径,它只用于特定 host 环境。 + ## 验收分层 ### A. Shell baseline(当前可验) @@ -48,13 +55,15 @@ - [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 数据下仍有明确文案 +- [x] 真实连接激活可区分 `loading` / `success` / `failure` +- [x] 真实连接失败后,错误能在 `Inspector` 与 `Status & Activity` 中持续可见 +- [x] 真实 schema browser 可展示 schema / object / column 层级 +- [x] empty schema 在 live 数据下有明确文案 +- [x] connection-failure fallback 会把 browser state 渲染为 `unavailable (restricted)` 并保留恢复文案 +- [x] successful live restricted schema 可与 empty / hidden 稳定区分;当前 runner 已直接观察到 `restricted_probe`、`Schema failure`、`schema access is restricted: restricted_probe` 与 query-blocking 文案,见 [CMP-64](/CMP/issues/CMP-64) - [x] `Query Editor` 可针对真实活动连接执行查询 - [x] 真实查询可区分 `running` / `success` / `empty` / `error` -- [ ] 真实结果区在常见终端尺寸下仍可浏览 +- [x] 真实结果区在常见终端尺寸下仍可浏览 - [x] export 结果会给出明确成功 / 失败反馈 ### D. usable-v1 状态恢复与键盘一致性(当前可按现有实现复核) @@ -67,6 +76,17 @@ - [x] `[` / `]` 在 `Query Editor` 与 `Results` 之外不会制造隐藏状态变化 - [x] 当前对外文档已与实现一致,不再声明不存在的 `l` / `e` 快捷键 +### E. 连接管理工作流(源码已落地,待 QA 走真实 TTY) + +- [ ] `Connections` 视图展示的列表来自 profile store,而不是硬编码 demo-only 列表 +- [ ] `n` 可进入新增流程,并在中间 `Query Editor` 显示分步式字段表单 +- [ ] `e` 可编辑已保存 profile,且 `Session password` 不会明文回显 +- [ ] `t` 会对 staged profile 执行 connect + inspect 测试,并在 `Results` / `Status & Activity` 中保留反馈 +- [ ] `s` 会保存非 secret 字段、复用 session secret、设置 active profile,并直接进入查询工作流 +- [ ] `d` 会进入删除确认;只有 `Enter` 才删除,`Esc` 会取消 +- [ ] `DBTOOL_TUI_PROFILE_STORE` 可将 smoke 隔离到临时 store,不污染长期配置 +- [ ] 持久化的 profile 文件不包含密码或 `password` JSON 字段 + ### C. 非交互 / 发布前路径(当前必须显式保留限制) - [x] QA 已确认 `dbtool-tui` 需要 TTY,不能用 `--help` 替代交互 smoke @@ -77,9 +97,6 @@ ## 当前 blocker -- [ ] 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` - [ ] 当前 runner 默认裸 `cargo` 仍会被空的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 环境变量绊倒,runbook 需要显式写出 override - [ ] release artifact / packaged binary 仍缺真实 smoke 证据 @@ -100,13 +117,23 @@ - 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 容器 +- PostgreSQL failure visibility:TTY 脚本日志 `/tmp/dbtool-tui-postgres-auth-failure.log` 已直接观察到 `Connection Failure`、`Recovery path`、`postgres authentication failed` 与 browser `unavailable (restricted)` 占位态 +- PostgreSQL empty result:TTY 脚本日志 `/tmp/dbtool-tui-postgres-empty-result.log` 已直接观察到 `empty_recent_tickets.sql`、`No rows returned`、`0 rows returned in 77 ms` 与 `Execution: Empty` +- PostgreSQL bad SQL:TTY 脚本日志 `/tmp/dbtool-tui-postgres-bad-sql.log` 已直接观察到 `bad_syntax.sql`、`Query error` 与 `query failed: syntax error ...` +- MySQL empty schema:TTY 脚本日志 `/tmp/dbtool-tui-mysql-empty-schema.log` 已直接观察到 `Schema dbtool_demo is empty. Schema dbtool_demo has no tables or views in the current target.` +- MySQL browse-stability:`28x100` TTY 脚本日志 `/tmp/dbtool-tui-mysql-browse-stability.log` 已直接观察到 `4 rows returned in 2 ms`、`Result columns paged to 2-5 of 6` 与结果行浏览更新 +- PostgreSQL restricted-schema probe:2026-03-31 当前 runner 已直接复验 `/tmp/cmp64-role-probe-v4.json`、`/tmp/cmp64-postgres-root-inspect-v4.json`、`/tmp/cmp64-postgres-restricted-schema-inspect-v4.stderr.txt`、`/tmp/cmp64-postgres-restricted-query-v4.stderr.txt` 与 `/tmp/cmp64-tui-restricted-live-v6.log`;证据显示 `dbtool` 为非 superuser、root inspect 保留 `restricted_probe`、显式 inspect 返回 restricted inspect error、直接 query 返回 permission denied,且 release TUI log 已直接显示 `Schema Browser` / `Inspector` / `Status & Activity` / query-blocking copy 的 restricted 文案 ## 当前不应误判为已通过的能力 - [x] 真实 shared app 连接切换 -- [ ] 真实 inspect / schema browser +- [x] 真实 inspect / schema browser - [x] 真实 query 执行 -- [ ] 真实 empty / error 结果处理 +- [x] 真实 empty / error 结果处理 - [x] 真实 export 反馈 +- [ ] TUI 连接管理的真实 TTY 直证 +- [ ] 连接测试后立即保存并激活自定义 profile 的 QA 直证 +- [ ] session-only secret 输入 / 复用的 QA 直证 +- [ ] 无需预设 host override 即可完成 PostgreSQL / MySQL 自定义配置 - [ ] TUI CI smoke - [ ] TUI release artifact smoke diff --git a/TUI_BACKEND_CONTRACT.md b/TUI_BACKEND_CONTRACT.md index e81f0b8..b974a54 100644 --- a/TUI_BACKEND_CONTRACT.md +++ b/TUI_BACKEND_CONTRACT.md @@ -1,23 +1,34 @@ # TUI Backend Contract -日期:2026-03-27 +日期:2026-04-02 作者:Senior Backend Engineer ## 1. 当前后端边界 - `crates/db-core`:数据库无关的领域对象与基础校验。 -- `crates/db-config`:连接 profile、密码环境变量注入与脱敏摘要。 +- `crates/db-config`:连接 profile、versioned profile store、active profile 语义、session-only secret 缓存、密码环境变量注入与脱敏摘要。 - `crates/db-drivers`:PostgreSQL / MySQL / SQLite 驱动实现与差异收敛。 -- `crates/db-app`:`connect` / `inspect` / `query` / `export` 共享应用编排、结构化结果、统一错误对象,以及 TUI worker 可复用的执行状态。 +- `crates/db-app`:`connect` / `inspect` / `query` / `export` 共享应用编排,以及 profile `load` / `save` / `delete` / `activate` / `resolve` 共享入口、结构化结果、统一错误对象,与 TUI worker 可复用的执行状态。 - `apps/cli`:参数解析、stdout/stderr 渲染、退出码。 ## 2. 持久化边界 -- 当前产品仍是单次命令执行模型,没有连接配置持久化、历史记录持久化或后台状态存储。 +- 当前 backend 新增了一条受控本地持久化边界:`db_config::ConnectionProfileStore`。 +- store 仅持久化: + - `version` + - `active_profile` + - 不含 secret 的 `ConnectionProfile[]` +- 当前 store 不持久化: + - 明文密码 / session secret + - 查询历史 + - 导出历史 + - 后台任务状态 - 当前写路径只有: + - 调用方显式传入路径的 profile store JSON 文件 - `export` 明确指定的输出文件 - SQLite 目标数据库本身(由用户选择) -- TUI 第二阶段 live integration 仍不引入新的数据库、本地缓存库或后台 daemon 存储契约。 +- `SessionSecretStore` 只存在于当前进程内存;重启后不恢复。 +- 当前阶段仍不引入新的数据库、本地缓存库或后台 daemon 存储契约,也不引入 migration chain。 ## 3. 共享请求对象 @@ -29,7 +40,30 @@ TUI worker 应直接构造共享 Rust 对象,不要拼接 CLI flags,更不 - 说明: - `name` 用于当前会话内的目标标识 - `target` 复用 `db_core::ConnectionTarget` - - `password_env_var` 只作为脱敏标签保留;secret 仍通过环境变量注入,不进入持久化 + - `password_env_var` 只作为脱敏标签 / advanced fallback 保留 + - `ConnectionTarget.password` 可在运行态存在,但禁止进入 `ConnectionProfileCatalog` + +### Connection Management + +- profile store:`db_config::ConnectionProfileStore` +- catalog:`db_config::ConnectionProfileCatalog` +- session secret cache:`db_config::SessionSecretStore` +- 保存请求:`db_app::SaveProfileRequest` + - `profile` + - `set_active` + - `session_secret`:`Preserve | Set(String) | Clear` +- 共享入口: + - `db_app::load_profile_catalog` + - `db_app::save_profile` + - `db_app::delete_profile` + - `db_app::activate_profile` + - `db_app::resolve_profile` + - `db_app::resolve_active_profile` + - `db_app::connect_saved_profile` +- 约束: + - catalog 内 profile 必须无 secret;若 `target.password` 存在,视为运行态数据,只能通过 session secret 路径保留 + - `active_profile` 必须指向已存在 profile,或为 `null` + - 同名 profile 视为 upsert,不引入第二份记录 ### Inspect @@ -80,6 +114,20 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 - `status` - 当前 `status` 固定为 `connected` +### Connection Management + +- `load_profile_catalog` / `save_profile` / `delete_profile` / `activate_profile` 返回 `ProfileCatalogResponse` +- 字段: + - `active_profile_name` + - `profiles[]` + - `target` + - `is_active` + - `secret_source`:`none | session | env_var` +- `resolve_profile` / `resolve_active_profile` 返回运行态 `ConnectionProfile` + - 若 session secret 存在,优先注入该 secret + - 否则再尝试 `password_env_var` + - 解析阶段仍不把 secret 暴露到 summary / doc / 持久化内容 + ### Inspect - 返回 `InspectResponse` @@ -92,7 +140,13 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 - `schemas` - `tables` - `columns` +- 当 `payload.kind=schemas` 时,每个 schema item 额外包含: + - `name` + - `availability`:`ready | restricted` + - `note`:可选;当 `availability=restricted` 时给出权限说明 - `payload.items` 允许为空;空数组不是错误 +- PostgreSQL root inspect 现会保留 restricted schema 名称,并通过 item-level `availability` 暴露权限状态 +- 当显式 schema inspect 命中存在但当前连接无权访问的 PostgreSQL schema 时,返回 `AppError.kind=inspect`,不再把 restricted schema 误报为 empty ### Query @@ -129,6 +183,7 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 - `inspect` - `query` - `export` +- `config` 这组分类是 TUI banner、QA 断言和未来 GUI 状态映射的当前稳定基础。 @@ -141,7 +196,7 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 `crates/db-app` 现提供: -- `AppOperation`:`connect | inspect | query | export` +- `AppOperation`:`connect | inspect | query | export | load_profiles | save_profile | delete_profile | activate_profile` - `OperationState`:`running | success | empty | error` - `AppEvent`:worker / channel 推荐 envelope @@ -160,7 +215,7 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 - `inspect` - `success`:返回非空 schema / table / column 集合 - `empty`:返回空集合,但请求本身成功 - - `error`:inspect 失败 + - `error`:inspect 失败;包括 restricted schema 等显式 scope 无权访问场景 - `query` - `success`:返回至少一行,或 `rows_affected` 非空 - `empty`:返回零行且 `rows_affected == null` @@ -168,6 +223,9 @@ TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文 - `export` - `success`:文件写入成功;`row_count == 0` 仍算成功 - `error`:导出校验、查询或文件写入失败 +- `load_profiles | save_profile | delete_profile | activate_profile` + - 当前先作为共享 operation 名称与 error.operation 保留,供连接管理 worker 接入 + - 若后续 channelize 这些动作,应继续沿用 `running | success | error`,不要另起一套状态词 示例: @@ -228,7 +286,9 @@ CLI JSON envelope 继续作为跨入口回归样本,但 TUI live path 不应 - TUI 直接依赖 `crates/db-app` 与 `db-core`,不解析 CLI stdout/stderr - UI 状态映射应优先使用 `OperationState`,不要自行发明另一套 success/empty/error 语义 +- UI 在 schema 根列表上应优先消费 item-level `availability` / `note`,不要把 restricted schema 再降级成 empty - `ConnectionSummary.endpoint` 与 `password_env_var` 仅用于展示脱敏上下文,不应用作 secret 来源 +- 连接管理 UI 应优先消费 `ProfileCatalogResponse.secret_source` 与 active profile 语义,不自行读取持久化文件 - `AppError.kind` 必须原样进入状态区 / inspector,避免把 `validation`、`connection`、`query` 混成统一“失败” - 若未来引入异步 worker 池,仍以共享 response/error 对象作为唯一 UI 数据源 @@ -238,6 +298,7 @@ CLI JSON envelope 继续作为跨入口回归样本,但 TUI live path 不应 - response -> `OperationState` 映射 - `AppEvent` / `AppError` 序列化 - 空 inspect / 空 query / command query 语义 + - profile save/load/activate 的 secret 非持久化边界 - `apps/cli` 回归继续覆盖: - `--result-format json` envelope - text/json 输出在相同 `db-app` 结果上的一致性 @@ -246,4 +307,4 @@ CLI JSON envelope 继续作为跨入口回归样本,但 TUI live path 不应 - CLI 文本文案可小幅演进,但 `db-app` 结构化对象字段应保持兼容。 - 新增数据库驱动时,优先扩展 `db-drivers` 与 `db-app`,不要在 TUI/CLI 内复制数据库语义。 -- 若未来引入本地持久化(例如连接历史),应先新增独立文档定义存储边界,再进入实现。 +- 若未来扩展 profile store schema(例如新增 profile id、secret provider、连接历史),必须先 bump version 并补迁移策略文档,再进入实现。 diff --git a/TUI_REGRESSION_CHECKLIST.md b/TUI_REGRESSION_CHECKLIST.md index 99cee6a..5e4b5e6 100644 --- a/TUI_REGRESSION_CHECKLIST.md +++ b/TUI_REGRESSION_CHECKLIST.md @@ -48,14 +48,16 @@ - [ ] `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)` 就绪后执行) +## 3. 第二阶段 live integration 回归(当前 runner demo path 已可复验) +- [ ] 记录本次是否仅验证内置 demo profile;不得把 prelaunch env override 误写成 in-app connection management - [ ] 真实连接列表可加载 - [ ] 真实连接激活可区分 loading / success / failure - [ ] 真实连接失败详情会持续显示在 `Inspector` 与 `Status & Activity` -- [ ] 真实 schema / object / column 可浏览 +- [x] 真实 schema / object / column 可浏览 - [ ] empty schema 有明确空状态 -- [ ] restricted schema 有明确失败状态 +- [x] connection-failure fallback 会把 browser state 渲染为 `unavailable (restricted)` +- [x] successful live restricted schema 可与 empty / hidden 稳定区分(见 `[CMP-64](/CMP/issues/CMP-64)`) - [ ] `Query Editor` 可对真实活动连接执行查询 - [ ] 真实查询会显示 running / success / empty / error - [ ] 真实 success 结果可浏览 @@ -67,8 +69,8 @@ ## 4. 源码 / CI / 发布前回归 -- [ ] `cargo build -p dbtool-tui` -- [ ] `cargo test -p dbtool-tui` +- [ ] `unset CARGO_REGISTRIES_CRATES_IO_PROTOCOL && cargo build -p dbtool-tui` +- [ ] `unset CARGO_REGISTRIES_CRATES_IO_PROTOCOL && cargo test -p dbtool-tui` - [ ] 内联测试与交互 smoke 都能在同一环境复现 - [ ] 仓库存在 TUI 专属 smoke runbook - [ ] GitHub Actions 覆盖 `dbtool-tui` @@ -88,13 +90,28 @@ - [x] 小终端日志含 `Resize the terminal` - [x] 已确认 `dbtool-tui --help` 不能替代 TTY smoke +## 2026-03-31 当前已回填项 + +- [x] `unset CARGO_REGISTRIES_CRATES_IO_PROTOCOL && cargo build -p dbtool-tui` +- [x] `unset CARGO_REGISTRIES_CRATES_IO_PROTOCOL && cargo test -p dbtool-tui` +- [x] `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui` +- [x] `scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui` +- [x] PostgreSQL failure visibility:已见 `Connection Failure`、`Recovery path`、`postgres authentication failed` +- [x] PostgreSQL empty result:已见 `No rows returned`、`Execution: Empty` +- [x] PostgreSQL bad SQL:已见 `Query error` +- [x] MySQL empty schema:已见 `Schema dbtool_demo is empty...` +- [x] MySQL browse-stability:已见 `Result columns paged to 2-5 of 6` +- [x] PostgreSQL restricted schema:已见 `restricted_probe`、`Schema failure`、`schema access is restricted: restricted_probe` 与 `Query execution blocked because schema restricted_probe is restricted...` +- [x] current runner 的 live integration 不再等待 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` + ## 当前阻塞中的回填项 -- [ ] 源码重建:当前 runner 缺 `cc` / `zig cc` -- [ ] 源码测试:当前 runner 缺 `cc` / `zig cc` -- [ ] live integration 回归:等待 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` +- [ ] 连接 profile 的新增 / 编辑 / 删除 / 保存:当前实现仍未提供可执行 UI path +- [ ] session-only secret entry / cache:当前只能记录为待实现,不能由 `DBTOOL_PASSWORD` 或 host override 冒充通过 +- [ ] 真实 schema / object / column 可浏览:仍需按 usable-v1 口径继续补连续操作证据 - [ ] CI smoke:仓库尚未覆盖 `dbtool-tui` - [ ] release artifact smoke:仓库尚未为 `dbtool-tui` 建立产物路径 +- [ ] packaged binary / 非开发者 runbook:仍缺真实复验证据 ## 当前键位一致性说明 diff --git a/TUI_SMOKE_RUNBOOK.md b/TUI_SMOKE_RUNBOOK.md index 8b8804b..8ff1b4f 100644 --- a/TUI_SMOKE_RUNBOOK.md +++ b/TUI_SMOKE_RUNBOOK.md @@ -12,6 +12,7 @@ - Linux runner,且可用 `script`(util-linux)。 - 已有可执行二进制: - 源码环境:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build -p dbtool-tui` + - release binary 复验:`CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build --release -p dbtool-tui` - 无 Rust 工具链环境:直接使用 `./target/debug/dbtool-tui` - 若当前 runner 的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 为空字符串,裸 `cargo` 会直接失败;请按上面的显式 override 执行。 @@ -25,6 +26,14 @@ grep -aq "Workspace" /tmp/dbtool-tui-smoke.log grep -aq "Loading" /tmp/dbtool-tui-smoke.log ``` +release binary 同口径: + +```bash +scripts/tui/smoke-tty.sh ./target/release/dbtool-tui 40 120 /tmp/cmp60-dbtool-tui-release-smoke.log +grep -aq "Workspace" /tmp/cmp60-dbtool-tui-release-smoke.log +grep -aq "Loading" /tmp/cmp60-dbtool-tui-release-smoke.log +``` + 小终端降级: ```bash @@ -45,6 +54,9 @@ scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui ``` +- 这两条命令只验证内置 demo profile 的 activate / inspect / query / export,不覆盖连接新增 / 编辑 / 删除 / 保存或 secret session cache。 +- 若 GUI host 或宿主机必须把 demo host 改到 `127.0.0.1`,请在启动前设置 `DBTOOL_TUI_POSTGRES_HOST` / `DBTOOL_TUI_POSTGRES_PORT` 与 `DBTOOL_TUI_MYSQL_HOST` / `DBTOOL_TUI_MYSQL_PORT`;这是 operator fallback,不得记成连接管理 happy path。 + - 若只想准备 demo stack 与 seed 数据: ```bash @@ -53,13 +65,42 @@ scripts/tui/demo-stack.sh seed scripts/tui/demo-stack.sh status ``` +## Connection management smoke + +若要复核新的 in-app 连接管理路径,建议先隔离 profile store: + +```bash +export DBTOOL_TUI_PROFILE_STORE=/tmp/dbtool-tui-profiles.json +rm -f "$DBTOOL_TUI_PROFILE_STORE" +cargo run -p dbtool-tui +``` + +进入 TUI 后按下面的手工路径复核: + +1. 按 `2` 进入 `Connections` +2. 按 `n` 新建 profile +3. 在中间 `Query Editor` 中用 `i` 编辑字段,`Enter` 提交字段 +4. 按 `t` 做 staged connect + inspect 测试 +5. 按 `s` 保存并激活 profile +6. 确认左侧列表、右侧 `Inspector` 和 `Status & Activity` 同步切到新活动连接 +7. 按 `e` 编辑已保存 profile,确认修改可再次测试并保存 +8. 按 `d` 删除 profile,确认 `Enter` 才执行删除,`Esc` 可取消 + +记录时至少包含: + +- `DBTOOL_TUI_PROFILE_STORE` 的实际路径 +- 新建 profile 的 driver 类型(SQLite / PostgreSQL / MySQL) +- `t` 的结果文案 +- `s` 后的激活结果与当前连接名称 +- 删除确认路径是否被 `Enter` / `Esc` 正确区分 + ## 非 TTY 预期行为 ```bash -./target/debug/dbtool-tui --help +./target/release/dbtool-tui --help ``` -2026-03-28 当前源码重建后二进制实测行为: +2026-03-31 当前 release binary 实测行为: - 退出码:`1` - `stderr`: @@ -77,9 +118,11 @@ scripts/tui/demo-stack.sh status - 正常尺寸至少确认日志里出现 `Workspace`、`Loading`。 - 小终端路径至少确认日志里出现 `Terminal Too Small`、`Resize the terminal`。 - network live 路径至少记录目标数据库、endpoint、导出路径和日志路径。 +- 若使用 host override,额外记录具体环境变量;不得把这条路径写成普通用户主流程。 - 若源码不可重建,要明确记为环境 blocker,不得伪造成产品失败。 ## CI 约束 - 当前仓库还没有正式接入 `dbtool-tui` 的 GitHub Actions smoke。 +- 当前仓库也还没有 `dbtool-tui` 的 packaged-artifact workflow / package script;`target/release/dbtool-tui` 只能记为 release-binary 级别证据,不能上抬成已验证的 distributable artifact。 - 后续 CI 若补齐,应直接复用 `scripts/tui/smoke-tty.sh`,不要重新发明另一套启动口径。 diff --git a/TUI_TEST_STRATEGY.md b/TUI_TEST_STRATEGY.md index 596d806..bc59c25 100644 --- a/TUI_TEST_STRATEGY.md +++ b/TUI_TEST_STRATEGY.md @@ -43,12 +43,17 @@ - 非交互限制:源码重建后的 `./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` 直接通过 +- PostgreSQL auth-failure visibility:`/tmp/dbtool-tui-postgres-auth-failure.log` 已见 `Connection Failure`、`Recovery path`、`postgres authentication failed` +- PostgreSQL empty / error result:`/tmp/dbtool-tui-postgres-empty-result.log` 已见 `0 rows returned in 77 ms`;`/tmp/dbtool-tui-postgres-bad-sql.log` 已见 `Query error` +- MySQL empty schema / browse stability:`/tmp/dbtool-tui-mysql-empty-schema.log` 已见 `Schema dbtool_demo is empty...`;`/tmp/dbtool-tui-mysql-browse-stability.log` 已见 `4 rows returned in 2 ms` 与 `Result columns paged to 2-5 of 6` +- PostgreSQL restricted-schema probe:2026-03-31 当前 runner 已直接复验 `/tmp/cmp64-role-probe-v4.json`、`/tmp/cmp64-postgres-root-inspect-v4.json`、`/tmp/cmp64-postgres-restricted-schema-inspect-v4.stderr.txt`、`/tmp/cmp64-postgres-restricted-query-v4.stderr.txt` 与 `/tmp/cmp64-tui-restricted-live-v6.log`;当前可直接观察到 non-superuser demo role、schema-level `restricted`、inspect error、query error 与 TUI query-blocking copy ### 当前缺口 +- 当前 repo 仍只有内置 `sqlite-local` / `reporting-postgres` / `orders-mysql` profile;连接 profile 的新增 / 编辑 / 删除 / 保存与 session-only secret cache 仍未进入当前可执行验收面。 - 当前 runner 默认裸 `cargo` 会因为空的 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL` 环境变量直接失败;runbook 必须显式写出 override - `.github/workflows/release-smoke.yml` 当前只覆盖 `dbtool` -- 当前已建立 PostgreSQL / MySQL live happy path,但仍缺 failure / empty / restricted / browse-stability 的 runner 直证 +- 当前 runner 已补齐 failure、empty、bad-SQL、browse-stability 与 PostgreSQL restricted-schema 的直证;剩余缺口已收敛为 packaged binary / CI / 非开发者复验,而不是 runner-side live semantics - release artifact / packaged binary 仍缺真实 smoke - 状态恢复与键盘一致性需要单独留痕,避免 QA 按旧文档复核不存在的快捷键 @@ -120,13 +125,14 @@ ## 当前优先级 1. 保住 shell baseline 证据,不让第二阶段重构把它冲掉 -2. 在已建立的 live happy path 上补 failure / empty / restricted / browse-stability 证据 -3. 补 TUI CI / packaged-binary / release smoke -4. 最后再补 TUI 专属 CI / release / runbook +2. 补 TUI CI / packaged-binary / release smoke +3. 固化非开发者可重复 runbook +4. 保持现有 live 证据不回退 ## 当前验收判断 - shell baseline:有预构建二进制级别的可重复 smoke,属“通过” - 源码层验证:当前 runner 已通过,但需要在 runbook 中固化 cargo 环境变量 override -- 第二阶段 live integration:当前 runner 内 PostgreSQL / MySQL demo happy path 已通过,但 failure / empty / restricted / browse-stability 仍未补齐 +- 第二阶段 live integration:当前 runner 内 PostgreSQL / MySQL demo happy path、failure visibility、empty / error result、empty schema、browse-stability 与 PostgreSQL restricted schema 已补齐直接证据;当前未就绪项已切换为 release / artifact / CI 证据 +- 连接管理:当前仍 blocked;当前 TUI 只支持内置 demo profile 与启动前 env override,不能把这组 operator 路径上抬成已交付的 profile CRUD / secret-management 验收 - 发布前状态:未就绪,因为 `dbtool-tui` 仍缺 CI 与 release artifact smoke diff --git a/USABLE_ACCEPTANCE_CHECKLIST.md b/USABLE_ACCEPTANCE_CHECKLIST.md index 4cd4d5b..6c4dd1d 100644 --- a/USABLE_ACCEPTANCE_CHECKLIST.md +++ b/USABLE_ACCEPTANCE_CHECKLIST.md @@ -4,7 +4,7 @@ - 当前结论:`no-go`。 - 原因 1:CLI 已有较强真实使用证据,但 `dbtool-usable-v1` 不能只按 CLI 通过来判定整体通过。 -- 原因 2:TUI 已形成 PostgreSQL / MySQL demo live happy path,但仍缺 failure / empty / restricted / browse-stability 等更广覆盖证据。 +- 原因 2:TUI 已形成 PostgreSQL / MySQL demo live happy path,并已补齐 PostgreSQL `restricted schema` runner-side 直证;当前 `no-go` 已收敛到 packaged artifact、跨平台 release runner 与 traceability 证据,而不是 restricted 语义缺口。 - 原因 3:发布链路仍缺 GitHub macOS / Windows runner 与 packaged binary 的真实执行证据。 - 当前统一台账见 `USABLE_EVIDENCE_LEDGER.md`,当前发布门槛结论与模板见 `USABLE_RELEASE_GATE.md`。 @@ -113,31 +113,33 @@ ## 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`: +1. 在发起宿主机或 sidecar live 执行前,先跑 `scripts/qa/test-failure-path-fixtures.sh`;若只想缩小到单一路径,可改跑 `scripts/qa/test-failure-path-fixtures.sh format` 或 `scripts/qa/test-failure-path-fixtures.sh sidecar`。 +2. 在 Docker-capable 宿主机按 `QA_RUNTIME_ENVIRONMENT.md` 拉起 `docker compose -f docker-compose.demo.yml up -d postgres mysql`,并执行两份 bootstrap 脚本。 +3. 按 `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`。 +4. 每条证据都必须留:UTC 时间、操作者、宿主机、execution path、binary path、`--version`、commit SHA、branch、完整命令、退出码、关键 JSON / 错误摘要、最终 verdict。 +5. 先把原始执行包回填到对应 issue / heartbeat 评论,再同步更新 `USABLE_EVIDENCE_LEDGER.md` 与 `USABLE_RELEASE_GATE.md`。 +6. 若 `status` / `state` / `error.kind` 任何一项偏离模板预期,不把它记成“补证完成”,而是单独拆 defect issue。 +7. 如需减少人工抄录,可在宿主机直接运行 `scripts/qa/run-failure-path-evidence.sh ./target/release/dbtool all ./tmp/failure-path-evidence`;当前 helper 会自动校验 `status` / `state` / `error.kind`、traceability 完整性,并对可检测到的密码泄露模式直接判 `fail`,但仍需逐条人工复核生成的 markdown 包。 +8. 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 证据。 +- TUI 的 failure / empty / browse-stability / PostgreSQL restricted schema 已有 runner 直证;当前整体 `no-go` 主要来自 packaged artifact、跨平台 release runner 与 traceability 证据缺口。 - 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 脚本。 +- 当前 PostgreSQL demo / TUI `reporting-postgres` profile 已可通过 non-superuser `dbtool` + restricted probe 稳定复现 `restricted schema`;runner-side 证据已存在于 `/tmp/cmp64-role-probe-v4.json`、`/tmp/cmp64-postgres-root-inspect-v4.json`、`/tmp/cmp64-postgres-restricted-schema-inspect-v4.stderr.txt`、`/tmp/cmp64-postgres-restricted-query-v4.stderr.txt` 与 `/tmp/cmp64-tui-restricted-live-v6.log`。 - GitHub macOS / Windows release runner 仍缺真实执行日志与产物 smoke 证据。 - release artifact / packaged binary 的 binary path 与 commit SHA / branch 仍待补齐,可追溯性不足。 diff --git a/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md b/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md index f57b759..3ab0b09 100644 --- a/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md +++ b/USABLE_APP_CLI_CONTRACT_SNAPSHOT.md @@ -83,10 +83,16 @@ - `schemas` - `tables` - `columns` +- 当 `payload.kind=schemas` 时,schema item 额外包含: + - `name` + - `availability`:`ready | restricted` + - `note`:可选 restricted 说明 - 当前状态语义: - 非空集合 => `success` - 空集合 => `empty` - 执行失败 => `error` +- PostgreSQL root inspect 现会保留 restricted schema 名称,并通过 schema item 暴露权限可见性 +- 显式 inspect 某个存在但当前连接无权访问的 PostgreSQL schema 时,当前返回 `error`,不再把 restricted schema 误判为 `empty` ### 4.3 Query @@ -218,6 +224,9 @@ - 当前没有 migration blocker - 当前没有新的 shared contract blocker - 当前没有新的 backend contract blocker +- 当前 restricted-schema 剩余缺口已收敛为: + - TUI / GUI consumer 侧消费 schema item `availability` + - live fixture 权限模型仍需 QA 与 demo 环境配合验证 ## 8. 本轮验证证据 diff --git a/USABLE_EVIDENCE_LEDGER.md b/USABLE_EVIDENCE_LEDGER.md index 1ed8a93..e4ae976 100644 --- a/USABLE_EVIDENCE_LEDGER.md +++ b/USABLE_EVIDENCE_LEDGER.md @@ -82,8 +82,10 @@ | 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 覆盖 | +| TUI / PostgreSQL live path | 网络数据库真实激活 | `verified` | `direct-run` | 2026-03-31 当前 runner 直接通过 `scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui`,并补齐 `/tmp/cmp64-role-probe-v4.json`、`/tmp/cmp64-postgres-root-inspect-v4.json`、`/tmp/cmp64-postgres-restricted-schema-inspect-v4.stderr.txt`、`/tmp/cmp64-postgres-restricted-query-v4.stderr.txt` 与 `/tmp/cmp64-tui-restricted-live-v6.log` | runner-side restricted / empty / failure 已补齐;剩余缺口转为 packaged artifact / CI / traceability | +| 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 导出成功 | 当前 runner 已补 happy path、failure、empty 与 browse-stability;剩余缺口不在 network-live 本身 | +| TUI / Linux release binary smoke | `target/release/dbtool-tui` 最小 TTY 启动 / 退出 | `verified` | `direct-run` | 2026-03-31 当前 runner 直接通过 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build --release -p dbtool-tui` 与 `scripts/tui/smoke-tty.sh target/release/dbtool-tui 40 120 /tmp/cmp60-dbtool-tui-release-smoke.log`;SHA256=`b02ab9be82ebc683c61cf193945a123a66ff181eb386607e95d3335bed84f339` | 仍缺 packaged artifact / CI contract | +| TUI / packaged artifact smoke | distributable archive / checksum / unpacked smoke | `blocked` | `doc-only` | 当前仓库尚无 `dbtool-tui` package script 或 release workflow entry | 需先建立 artifact contract,再补 Linux/macOS/Windows runner 证据 | | 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 日志 | @@ -91,9 +93,10 @@ ## 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. 每条记录都必须带: +1. 在发起宿主机或 sidecar live 执行前,先跑 `scripts/qa/test-failure-path-fixtures.sh`;若只想缩小范围,可改跑 `scripts/qa/test-failure-path-fixtures.sh format` 或 `scripts/qa/test-failure-path-fixtures.sh sidecar`。 +2. 在 Docker-capable 宿主机按 `QA_RUNTIME_ENVIRONMENT.md` 拉起 `postgres` / `mysql` 并完成 bootstrap。 +3. 优先按 `HOST_FAILURE_PATH_CHECKLIST.md` 的顺序逐条执行 6 个场景;记录格式仍以 `FAILURE_PATH_EVIDENCE_TEMPLATE.md` 为准,首次回填可直接套用 `FAILURE_PATH_EVIDENCE_SAMPLE.md`。 +4. 每条记录都必须带: - UTC 时间 - 操作者 / 宿主机 - binary path / `--version` @@ -102,11 +105,11 @@ - 退出码 - JSON 或错误摘要 - verdict -4. 回填顺序: +5. 回填顺序: - 先在 issue / 评论里贴原始执行包 - 再把 `CLI / PostgreSQL failure path`、`CLI / MySQL failure path` 两行改为 `verified` 或继续保留 `blocked` - 最后同步 `USABLE_RELEASE_GATE.md` -5. 若 `status` / `state` / `error.kind` 偏离模板预期,先拆 defect,再继续台账更新。 +6. 若 `status` / `state` / `error.kind` 偏离模板预期,先拆 defect,再继续台账更新。 ## Blocker / Risk 清单 @@ -114,13 +117,15 @@ - `dbtool-usable-v1` 仍不能把 TUI 视为“已完成签收”的跨库操作台;当前虽已有 PostgreSQL / MySQL live happy path,但 live 覆盖仍偏窄。 - PostgreSQL / MySQL 的 failure-path 当前已补到直接执行层并通过最新 sidecar 复验;这一条线当前主要保持回归即可。 -- TUI failure / empty / restricted / browse-stability 与 packaged-binary 证据仍未收口。 +- TUI packaged artifact 证据仍未收口;不过 2026-03-31 Linux runner 已补到 `target/release/dbtool-tui` 的 release-binary 直接 smoke,且 PostgreSQL restricted-schema runner evidence 已完成,剩余缺口已收敛到 artifact contract / CI path。 ### 环境 / 证据 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。 +- 当前 PostgreSQL demo / TUI `reporting-postgres` profile 已可通过 non-superuser `dbtool` 与 restricted probe 产出真实 schema-level restricted live 证据;runner-side blocker 已解除。 +- 当前仓库还没有 `dbtool-tui` 的 package script / workflow matrix;现有 release 直证只到 Linux release binary,不等于 distributable artifact 已验证。 - GitHub macOS / Windows release runner 仍缺真实运行日志与产物 smoke 记录。 - release artifact / packaged binary 的 binary path、commit SHA / branch 仍需补齐追溯性。 diff --git a/USABLE_PRE_RELEASE_CHECKLIST.md b/USABLE_PRE_RELEASE_CHECKLIST.md index 52d1d6a..8356992 100644 --- a/USABLE_PRE_RELEASE_CHECKLIST.md +++ b/USABLE_PRE_RELEASE_CHECKLIST.md @@ -3,7 +3,7 @@ ## 当前总判断 - 当前状态:`no-go` -- 主因:TUI 虽已具备真实跨库 demo live happy path,但更广的 live 覆盖与跨平台 release 证据仍未闭环 +- 主因:TUI runner-side live 覆盖已基本闭环,但 packaged artifact、跨平台 release runner 与 traceability 证据仍未闭环 ## 必须通过 @@ -15,6 +15,7 @@ - [x] TUI shell baseline 有本地运行证据 - [x] TUI / `sqlite-local` local live 已有局部证据,但只可记为 `partial` - [x] TUI 至少一条真实网络数据库 live path 通过 +- [x] TUI Linux release binary 已有本地 TTY smoke 证据 - [ ] GitHub Linux / macOS / Windows release runner 有真实执行证据 - [ ] 发布版本、binary path、commit SHA / branch 可追溯 @@ -31,7 +32,7 @@ ### 产品 blocker -- [ ] TUI 仍缺失败连接、empty / restricted schema、empty / error result 与结果浏览稳定性的完整 live 覆盖证据 +- [ ] TUI packaged artifact / 非开发者独立复验 / 跨平台 release 证据仍未闭环 - [ ] TUI 当前的 demo live happy path 仍不能单独替代长期可持续使用签收 - [ ] usable-v1 仍无法以单一产品口径声明“终端里可持续完成跨库任务” @@ -39,6 +40,7 @@ - [ ] 当前 QA runner 缺 `docker compose`,标准 runbook 仍不能原样复跑 - [ ] 当前 QA runner 不能直接走 `127.0.0.1:55432/53306`;当前有效入口是 `host.docker.internal` + TTY smoke 脚本 +- [ ] 当前仓库还没有 `dbtool-tui` 的 packaged artifact / CI contract;`target/release/dbtool-tui` 只能证明 release binary 可启动,不能证明 distributable artifact 已验收 - [ ] 缺 macOS / Windows runner 实际日志 - [ ] 缺 release artifact / packaged binary 的 binary path 与 commit SHA / branch 留档 diff --git a/USABLE_RELEASE_GATE.md b/USABLE_RELEASE_GATE.md index 15995ab..3d1eb4b 100644 --- a/USABLE_RELEASE_GATE.md +++ b/USABLE_RELEASE_GATE.md @@ -17,17 +17,19 @@ - TUI / `sqlite-local` local live 已有局部真实工作流证据,但只能记为 `partial` - TUI / PostgreSQL live demo path 已具备 2026-03-31 当前 runner 直接执行证据 - TUI / MySQL live demo path 已具备 2026-03-31 当前 runner 直接执行证据 +- TUI / Linux release binary 已具备 2026-03-31 当前 runner 直接执行证据(`target/release/dbtool-tui` + TTY smoke) - Linux artifact smoke 具备 2026-03-28 直接执行证据 ### 未满足 -- TUI 仍缺 failure / empty / restricted / result-browsing 等 live 覆盖证据,尚不足以宣布长期可持续使用体验完成 -- TUI release artifact / packaged binary 仍缺真实 smoke 证据 +- TUI packaged-binary / checksum / unpacked smoke 与跨平台 release runner 证据仍未闭环,尚不足以宣布长期可持续使用体验完成 +- TUI packaged artifact / checksum / unpacked smoke 仍缺真实证据 - GitHub macOS / Windows release runner 仍缺真实执行日志 -- 发布追溯性仍缺 binary path、commit SHA / branch 留档 +- 发布追溯性在线上 artifact 维度仍缺 binary path、commit SHA / branch 留档 ## PostgreSQL / MySQL failure-path 收口规则 +- 在发起宿主机或 sidecar live 执行前,先跑 `scripts/qa/test-failure-path-fixtures.sh`,必要时再缩小到 `format` / `sidecar` 子范围。 - 只有当 `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 已补齐。 @@ -37,7 +39,7 @@ ### 产品 blocker - TUI 已不再停留在纯 shell 演示;当前 runner 已直接通过 PostgreSQL / MySQL demo live happy path。 -- 但单条 happy path 还不能等价为“可持续使用”:失败连接、空 schema / restricted schema、空结果 / 错误结果、结果浏览稳定性仍缺完整 runner 证据。 +- 但单条 happy path 还不能等价为“可持续使用”:当前 runner 已补齐失败连接、空 schema、PostgreSQL restricted schema、空结果 / 错误结果与结果浏览稳定性证据;剩余 `no-go` 原因已切换为 artifact / CI / traceability 证据不足。 - usable-v1 还无法以单一产品口径声明“技术操作者可在终端中持续完成跨库任务”。 ### 环境 / 证据 blocker @@ -45,6 +47,8 @@ - 当前 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 脚本 +- 当前 PostgreSQL demo / TUI `reporting-postgres` profile 已可通过 non-superuser `dbtool` + restricted probe 形成真实 gate 证据;schema-level restricted 不再是当前 gate blocker +- 当前仓库仍没有 `dbtool-tui` 的 package script / workflow matrix,故 Linux release binary 直证不能替代 packaged artifact gate - 缺 GitHub macOS / Windows runner 实跑证据 - 缺 release artifact / packaged binary 的执行追溯性字段 @@ -53,6 +57,8 @@ - 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 导出。 +- 2026-03-31 当前 runner 已直接复验 `/tmp/cmp64-role-probe-v4.json`、`/tmp/cmp64-postgres-root-inspect-v4.json`、`/tmp/cmp64-postgres-restricted-schema-inspect-v4.stderr.txt`、`/tmp/cmp64-postgres-restricted-query-v4.stderr.txt` 与 `/tmp/cmp64-tui-restricted-live-v6.log`;证据显示 `dbtool` 为 non-superuser、CLI inspect/query 返回预期 restricted error,且 TUI 直接显示 `restricted_probe`、`Schema failure` 与 `Query execution blocked because schema restricted_probe is restricted...`。 +- 2026-03-31 当前 runner 已直接执行 `CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo build --release -p dbtool-tui` 与 `scripts/tui/smoke-tty.sh target/release/dbtool-tui 40 120 /tmp/cmp60-dbtool-tui-release-smoke.log`;`target/release/dbtool-tui` 的 SHA256 为 `b02ab9be82ebc683c61cf193945a123a66ff181eb386607e95d3335bed84f339`。 - 当前 `no-go` 判断继续成立,但原因已收敛为 TUI live 覆盖深度不足、跨平台 release 证据缺口与 release traceability 缺口,而不是“缺少 TUI network live path”或活跃的 CLI backend contract defect。 ## 通过规则 diff --git a/USABLE_TEST_STRATEGY.md b/USABLE_TEST_STRATEGY.md index e7bc4b0..052202d 100644 --- a/USABLE_TEST_STRATEGY.md +++ b/USABLE_TEST_STRATEGY.md @@ -67,11 +67,15 @@ - `state` - `error.kind` - 无敏感信息泄露 -5. 一轮执行完成后,同时回填: + - traceability 字段完整,不能退化成 `unknown-*` 或空值 +5. 提交宿主机 / sidecar 实测前,先跑离线夹具锁定 helper 与 wrapper 契约: + - `scripts/qa/test-failure-path-fixtures.sh` + - 需要单测某一路径时可改跑 `scripts/qa/test-failure-path-fixtures.sh format` 或 `scripts/qa/test-failure-path-fixtures.sh sidecar` +6. 一轮执行完成后,同时回填: - 原始 issue 评论 - `USABLE_EVIDENCE_LEDGER.md` - `USABLE_RELEASE_GATE.md` -6. 若任何场景的 `error.kind` 与模板不一致,优先拆 defect issue,而不是把台账改成 `verified`。 +7. 若任何场景的 `error.kind` 与模板不一致,优先拆 defect issue,而不是把台账改成 `verified`。 ## Backend 验收要求 @@ -98,6 +102,7 @@ ## 下一轮验证重点 - 按 `HOST_FAILURE_PATH_CHECKLIST.md` 补齐 PostgreSQL / MySQL failure-path 6 条场景证据;首次回填可直接参考 `FAILURE_PATH_EVIDENCE_SAMPLE.md` +- 在每轮 live failure-path 执行前,先跑 `scripts/qa/test-failure-path-fixtures.sh` 锁定 markdown / sidecar 证据契约 - 补齐 usable-v1 口径下的 TUI live SQLite 完整证据 - 追踪 `CMP-42` / `CMP-43` 是否给出可被 QA 直接复用的验收入口 - 补齐 GitHub macOS / Windows release runner 的真实 smoke 证据 diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index ba9b93f..ae1e159 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -5,7 +5,7 @@ use std::process::ExitCode; use db_app::{ self, AppError, ColumnItem, ConnectResponse, ExportResponse, InspectPayload, InspectResponse, - OperationState, QueryResponse, SchemaItem, TableItem, + OperationState, QueryResponse, SchemaAvailability, SchemaItem, TableItem, }; use db_config::ConnectionProfile; use db_core::{ @@ -794,7 +794,13 @@ fn render_query_text(response: &QueryResponse) -> String { } fn render_schema(schema: &SchemaItem) -> String { - schema.name.clone() + match schema.availability { + SchemaAvailability::Ready => schema.name.clone(), + SchemaAvailability::Restricted => match &schema.note { + Some(note) => format!("{} [restricted] - {}", schema.name, note), + None => format!("{} [restricted]", schema.name), + }, + } } fn render_table(table: &TableItem) -> String { @@ -820,6 +826,22 @@ mod tests { #[cfg(unix)] use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + #[test] + fn render_schema_marks_restricted_entries() { + let rendered = render_schema(&SchemaItem { + name: String::from("restricted_probe"), + availability: SchemaAvailability::Restricted, + note: Some(String::from( + "schema access is restricted: restricted_probe", + )), + }); + + assert_eq!( + rendered, + "restricted_probe [restricted] - schema access is restricted: restricted_probe" + ); + } + #[test] fn parse_query_args_collects_params() { let args = vec![ diff --git a/apps/tui/README.md b/apps/tui/README.md index 517d2c9..308f9fd 100644 --- a/apps/tui/README.md +++ b/apps/tui/README.md @@ -9,11 +9,14 @@ - 独立 `dbtool-tui` 应用入口 - 稳定的六区工作台布局 - 顶部视图切换与底部快捷键提示 -- 连接管理列表、当前连接上下文和失败连接可视化区分 +- 基于 shared profile store 的连接管理列表、当前连接上下文和失败连接可视化区分 +- `Connections` 视图内的分步式新增 / 编辑 / 删除 / 测试 / 保存并激活工作流 +- session-only secret 输入与复用;密码不会写入持久化 profile 文件 - 键盘驱动的连接切换工作流,以及连接 `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 路径 +- shared inspect 返回 schema-level availability 时,`Schema Browser` / `Inspector` / `Status & Activity` 可稳定区分 `ready / empty / restricted` - 查询草稿切换、基础键入编辑、执行触发与执行状态反馈 - 成功 / 空结果 / 错误三类查询结果展示,以及宽表列分页提示 - 当前结果集导出到 `/tmp` 的 CSV / JSON 反馈 @@ -25,8 +28,8 @@ 当前不包含: - 多连接并发会话 -- 自定义新增 / 编辑连接表单 -- 非 demo 自定义 network profile +- 跨重启持久化 secret 管理 +- 多步向导之外的复杂连接模板或批量导入 - 完整 SQL 编辑器能力 ## usable-v1 口径边界 @@ -54,6 +57,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 数据。 +保存的 profile 默认写入 `~/.config/dbtool/tui-profiles.json`;若需要隔离测试,可在启动前设置 `DBTOOL_TUI_PROFILE_STORE=/tmp/dbtool-tui-profiles.json`。 如果当前 runner 缺少 Rust 工具链,可先使用已有产物: @@ -98,42 +102,49 @@ scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.l - `Tab` / `Shift+Tab`:切换焦点面板 - `←` / `→` 或 `1` / `2` / `3`:切换顶部视图 - `↑` / `↓`:移动连接、schema/object 或结果行选择 -- `Enter`:在 `Connections` 焦点下激活所选连接;在 `Schema Browser` 焦点下展开 / 折叠 schema 或确认对象;在 `Query Editor` 焦点下执行当前查询 -- `i`:在 `Query Editor` 中进入基础插入模式 +- `Enter`:在 `Connections` 焦点下激活所选连接;在 `Connections` 视图的 `Query Editor` 下循环 driver 或提交字段编辑;在 `Schema Browser` 焦点下展开 / 折叠 schema 或确认对象;在 `Query Editor` 焦点下执行当前查询 +- `n` / `e` / `d`:在 `Connections` 视图中新增 / 编辑 / 删除保存的 profile +- `i`:在 `Connections` 视图中编辑当前字段;在工作区 `Query Editor` 中进入 SQL 基础插入模式 +- `t`:在连接表单中测试当前 staged profile(connect + inspect) +- `s`:在连接表单中保存并激活当前 staged profile - `x`:将当前结果集导出为 CSV 到 `/tmp` - `j`:将当前结果集导出为 JSON 到 `/tmp` - `[` / `]`:在 `Query Editor` 中切换查询草稿;在 `Results` 中横向翻页宽表列 - `r`:清空当前 query / export 反馈,回到 `Ready` -- `Esc`:回到默认工作台 +- `Esc`:取消当前连接管理动作,或回到默认工作台 - `q`:退出 ## 状态恢复与键位一致性 -- `Esc` 在两个场景下都承担“回到稳定态”的职责:编辑模式下先退回 navigate mode;普通导航下回到默认 `Workspace` + `Query Editor` 焦点。 +- `Esc` 在三个场景下都承担“回到稳定态”的职责:连接表单 / 删除确认时先取消当前动作;编辑模式下退回 navigate mode;普通导航下回到默认 `Workspace` + `Query Editor` 焦点。 - `r` 是唯一的显式恢复键:会同时清空 query 结果、query 错误、export 成功/失败反馈,并把界面状态收口回 `Ready`。 -- `[` / `]` 只在两个焦点区生效:`Query Editor` 下切换草稿,`Results` 下横向翻页;其他面板按下不会触发隐藏状态变化。 +- `[` / `]` 只在工作区的两个焦点区生效:`Query Editor` 下切换草稿,`Results` 下横向翻页;其他面板按下不会触发隐藏状态变化。 - `x` / `j` 只在当前工作区已有“成功且可导出”的结果集时成立;若查询仍在运行或当前没有可导出结果,会保留明确错误/提示,而不是静默失败。 -- `Enter` 的语义由当前焦点决定:`Connections` = 激活连接,`Schema Browser` = 展开/确认对象,`Query Editor` = 执行查询;插入模式下 `Enter` 只插入换行。 +- `Enter` 的语义由当前视图和焦点共同决定:`Connections` = 激活连接,连接表单中的 `Query Editor` = 提交字段 / 循环 driver,`Schema Browser` = 展开/确认对象,工作区 `Query Editor` = 执行查询;插入模式下 `Enter` 只提交当前字段或插入换行。 ## 快速测试路径 如果你只是想快速走通一遍当前可见工作流,推荐按下面步骤操作: 1. 启动 `cargo run -p dbtool-tui` -2. 等待默认连接 `sqlite-local` 从 `Loading` 变成可用状态 -3. 按 `Tab` 把焦点切到 `Schema Browser` -4. 用 `↑` / `↓` 选择 `main.accounts` 或 `main.tickets` -5. 按 `Tab` 切到 `Query Editor` -6. 先不要按 `i`,直接按 `[` / `]` 切换草稿 -7. 选中 `account_ticket_summary.sql` 后按 `Enter`,确认成功结果 -8. 切到 `empty_recent_tickets.sql` 后按 `Enter`,确认空结果 -9. 切到 `bad_syntax.sql` 后按 `Enter`,确认错误结果持续留在工作区 -10. 切到 `ticket_export_preview.sql` 后按 `Enter`,成功后按 `x` 或 `j`,确认导出反馈和 `/tmp` 路径 +2. 按 `2` 进入 `Connections` 视图,确认左侧列表来自 profile store +3. 按 `n` 新建 profile,在中间 `Query Editor` 里用 `i` 编辑字段、`t` 测试连接、`s` 保存并激活 +4. 等待活动连接从 `Loading` 变成 `Healthy` +5. 按 `Tab` 把焦点切到 `Schema Browser` +6. 用 `↑` / `↓` 选择 `main.accounts` 或 `main.tickets` +7. 按 `Tab` 切到 `Query Editor` +8. 先不要按 `i`,直接按 `[` / `]` 切换草稿 +9. 选中 `account_ticket_summary.sql` 后按 `Enter`,确认成功结果 +10. 切到 `empty_recent_tickets.sql` 后按 `Enter`,确认空结果 +11. 切到 `bad_syntax.sql` 后按 `Enter`,确认错误结果持续留在工作区 +12. 切到 `ticket_export_preview.sql` 后按 `Enter`,成功后按 `x` 或 `j`,确认导出反馈和 `/tmp` 路径 ## 常见困惑 - `Inserted a newline. Press Esc, then Enter to run.`:表示你已经按了 `i` 进入编辑模式,然后按了 `Enter`;这时 `Enter` 不会执行查询,而是插入换行。按 `Esc` 退出编辑模式后,再按 `Enter` 才是执行查询。 -- 当前不能自定义输入数据库连接:这是当前范围外能力。当前 TUI 只提供内置的 `sqlite-local`、Postgres、MySQL 示例连接,用于验证工作台流程和状态反馈。 +- 当前保存的 profile 文件不包含密码:界面里的 `Session password` 只在本次 TUI 进程中可用;如果需要跨重启复用,请配置 `Password env`。 +- 如果修改了 profile store 路径,建议用临时文件如 `DBTOOL_TUI_PROFILE_STORE=/tmp/dbtool-tui-profiles.json` 做 smoke,避免污染长期配置。 +- 若 GUI host 或宿主机需要把 demo network host 从 `host.docker.internal` 改到 `127.0.0.1`,只能在启动前设置 `DBTOOL_TUI_POSTGRES_HOST` / `DBTOOL_TUI_POSTGRES_PORT` 与 `DBTOOL_TUI_MYSQL_HOST` / `DBTOOL_TUI_MYSQL_PORT`;这属于 operator fallback,不等于连接管理已交付。 - 如果觉得界面文字偏多:本轮已先做一轮 Help / Status / Connections 文案收敛;当前版本仍偏向 QA / 契约验证界面,后续再继续收敛文案。 ## 界面验收 @@ -142,20 +153,22 @@ QA 可按以下步骤复核: 1. 启动后能看到 `Connections`、`Schema Browser`、`Query Editor`、`Results`、`Inspector`、`Status & Activity` 六个区域 2. 默认活动连接应为 `sqlite-local`,且 `Connections` 列表中能区分当前连接、可选连接和失败连接 -3. 使用 `2` 进入 `Connections` 视图后,可用 `↑` / `↓` 移动连接选择 -4. 在 `Connections` 焦点下按 `Enter` 激活失败连接时,`Status & Activity` 与 `Inspector` 会持续显示可读错误,而不是只给瞬时提示 -5. 启动后或回到 `sqlite-local` 后,连接状态会先进入 `Loading`,随后在 `Schema Browser` 焦点下可浏览 `main.accounts` 和 `main.tickets` -6. 选中对象后,`Results` 会展示列定义,`Inspector` 会展示当前 schema / object / column 上下文 -7. 在 `Query Editor` 焦点下可用 `[` / `]` 切换查询草稿,`i` 进入基础输入模式,`Enter` 触发执行 -8. `account_ticket_summary.sql` 应返回真实结果行,`empty_recent_tickets.sql` 应显示空结果,`bad_syntax.sql` 应显示结构化错误 -9. `ticket_export_preview.sql` 执行成功后,按 `x` 或 `j` 应在工作区内看到导出成功反馈和 `/tmp` 输出路径 -10. 成功结果在 `Results` 中以表格方式展示,宽表会给出列分页提示,长结果仍可用 `↑` / `↓` 浏览 -11. 错误状态会保留可读错误信息,不退化成空白或瞬时提示 -12. 使用 `Tab` / `Shift+Tab` 时,当前焦点区域边框高亮会移动 -13. 使用 `←` / `→` 或 `1` / `2` / `3` 时,中间内容区会随视图切换 -14. 使用 `r` 时,query / export 反馈会清空并回到 `Ready` -15. 将终端缩小到低于 `100x28` 时,会出现尺寸不足提示而不是错乱布局 -16. 使用 `Esc` 可恢复默认工作台,使用 `q` 可稳定退出 +3. 使用 `2` 进入 `Connections` 视图后,可用 `↑` / `↓` 移动连接选择,并在 `Results` / `Inspector` 里看到 profile store 与 secret source 摘要 +4. 按 `n` 可进入新增流程;中间 `Query Editor` 会切换成分步式字段表单,`i` 进入字段编辑,`t` 测试连接,`s` 保存并激活 +5. 编辑流程下 `Session password` 只显示 session-only 提示,不会回显明文密码 +6. 在 `Connections` 焦点下按 `Enter` 激活失败连接时,`Status & Activity` 与 `Inspector` 会持续显示可读错误,而不是只给瞬时提示 +7. 启动后或回到 `sqlite-local` 后,连接状态会先进入 `Loading`,随后在 `Schema Browser` 焦点下可浏览 `main.accounts` 和 `main.tickets` +8. 选中对象后,`Results` 会展示列定义,`Inspector` 会展示当前 schema / object / column 上下文 +9. 在工作区 `Query Editor` 焦点下可用 `[` / `]` 切换查询草稿,`i` 进入基础输入模式,`Enter` 触发执行 +10. `account_ticket_summary.sql` 应返回真实结果行,`empty_recent_tickets.sql` 应显示空结果,`bad_syntax.sql` 应显示结构化错误 +11. `ticket_export_preview.sql` 执行成功后,按 `x` 或 `j` 应在工作区内看到导出成功反馈和 `/tmp` 输出路径 +12. 成功结果在 `Results` 中以表格方式展示,宽表会给出列分页提示,长结果仍可用 `↑` / `↓` 浏览 +13. 错误状态会保留可读错误信息,不退化成空白或瞬时提示 +14. 使用 `Tab` / `Shift+Tab` 时,当前焦点区域边框高亮会移动 +15. 使用 `←` / `→` 或 `1` / `2` / `3` 时,中间内容区会随视图切换 +16. 使用 `r` 时,query / export 反馈会清空并回到 `Ready` +17. 将终端缩小到低于 `100x28` 时,会出现尺寸不足提示而不是错乱布局 +18. 使用 `Esc` 可取消当前连接管理动作或恢复默认工作台,使用 `q` 可稳定退出 ## 当前限制 diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs index c85f391..613dd02 100644 --- a/apps/tui/src/main.rs +++ b/apps/tui/src/main.rs @@ -12,9 +12,13 @@ use crossterm::{ }; use db_app::{ AppError, AppErrorKind, ConnectionSummary, ExportResponse, InspectPayload, OperationState, - QueryResponse, + QueryResponse, SaveProfileRequest, SchemaAvailability as AppSchemaAvailability, SchemaItem, + SecretSource, SessionSecretInput, +}; +use db_config::{ + ConfigError, ConnectionProfile, ConnectionProfileCatalog, ConnectionProfileStore, + SessionSecretStore, }; -use db_config::ConnectionProfile; use db_core::{ ConnectionTarget, ConnectionTransport, DatabaseKind, ExportFormat, ExportRequest, InspectRequest, QueryRequest, QuerySource, @@ -416,19 +420,21 @@ struct ConnectionLoadResult { browser_schemas: &'static [BrowserSchema], } +#[derive(Clone)] struct ConnectionRecord { profile: ConnectionProfile, summary: ConnectionSummary, - environment_label: &'static str, - database_label: &'static str, - mode_label: &'static str, - query_qualifier: &'static str, + secret_source: SecretSource, + environment_label: String, + database_label: String, + mode_label: String, + query_qualifier: String, validation_label: String, status: ConnectionHealth, browser_schemas: &'static [BrowserSchema], expanded_schemas: Vec, selected_browser_row: usize, - details: &'static [&'static str], + details: Vec, failure_message: Option, } @@ -579,14 +585,318 @@ impl ConnectionRecord { } } - fn runtime_profile(&self) -> ConnectionProfile { - let password = self - .profile - .password_env_var - .as_ref() - .and_then(|name| env::var(name).ok()); + fn runtime_profile(&self, secrets: &SessionSecretStore) -> ConnectionProfile { + secrets.materialize_profile(&self.profile) + } +} - self.profile.clone().with_password(password) +#[derive(Clone, Debug, Eq, PartialEq)] +enum ConnectionWorkflow { + Browsing, + Editing(ConnectionFormState), + DeleteConfirm { profile_name: String }, +} + +impl ConnectionWorkflow { + fn is_modal(&self) -> bool { + !matches!(self, Self::Browsing) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectionFormPurpose { + Create, + Edit, +} + +impl ConnectionFormPurpose { + fn label(self) -> &'static str { + match self { + Self::Create => "Create Profile", + Self::Edit => "Edit Profile", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectionFormField { + ProfileName, + DriverKind, + SqlitePath, + Host, + Port, + Database, + Username, + PasswordEnvVar, + SessionPassword, +} + +impl ConnectionFormField { + fn label(self) -> &'static str { + match self { + Self::ProfileName => "Profile name", + Self::DriverKind => "Driver", + Self::SqlitePath => "SQLite path", + Self::Host => "Host", + Self::Port => "Port", + Self::Database => "Database", + Self::Username => "Username", + Self::PasswordEnvVar => "Password env", + Self::SessionPassword => "Session password", + } + } + + fn hint(self) -> &'static str { + match self { + Self::ProfileName => "Human-readable target name used in the connection list.", + Self::DriverKind => "Press Enter to cycle sqlite / postgres / mysql.", + Self::SqlitePath => "Local SQLite file path. No password is stored.", + Self::Host => "TCP host for PostgreSQL / MySQL targets.", + Self::Port => "TCP port for PostgreSQL / MySQL targets.", + Self::Database => "Database name or default schema container.", + Self::Username => "Optional username. Leave blank for driver defaults.", + Self::PasswordEnvVar => "Optional env var fallback for passwords across restarts.", + Self::SessionPassword => "Session-only password. Never persisted to disk.", + } + } + + fn accepts_text(self) -> bool { + !matches!(self, Self::DriverKind) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ConnectionFormState { + purpose: ConnectionFormPurpose, + original_name: Option, + selected_field: usize, + name: String, + kind: DatabaseKind, + path: String, + host: String, + port: String, + database: String, + username: String, + password_env_var: String, + session_password: String, + preserved_session_secret: Option, +} + +impl ConnectionFormState { + fn new_create() -> Self { + Self { + purpose: ConnectionFormPurpose::Create, + original_name: None, + selected_field: 0, + name: String::from("sqlite-local-copy"), + kind: DatabaseKind::Sqlite, + path: String::from("examples/tmp/dbtool-demo.sqlite"), + host: String::from("127.0.0.1"), + port: String::from("55432"), + database: String::from("dbtool_demo"), + username: String::from("dbtool"), + password_env_var: String::from("DBTOOL_PASSWORD"), + session_password: String::new(), + preserved_session_secret: None, + } + } + + fn from_record(record: &ConnectionRecord, secrets: &SessionSecretStore) -> Self { + let (path, host, port, database, username) = match &record.profile.target.transport { + ConnectionTransport::File { path } => ( + path.display().to_string(), + String::new(), + String::new(), + String::new(), + String::new(), + ), + ConnectionTransport::Tcp { host, port } => ( + String::new(), + host.clone(), + port.to_string(), + record.profile.target.database.clone().unwrap_or_default(), + record.profile.target.username.clone().unwrap_or_default(), + ), + }; + + let preserved_session_secret = if record.secret_source == SecretSource::Session { + secrets + .materialize_profile(&record.profile) + .target + .password + .clone() + } else { + None + }; + + Self { + purpose: ConnectionFormPurpose::Edit, + original_name: Some(record.profile.name.clone()), + selected_field: 0, + name: record.profile.name.clone(), + kind: record.profile.target.kind, + path, + host, + port, + database, + username, + password_env_var: record.profile.password_env_var.clone().unwrap_or_default(), + session_password: String::new(), + preserved_session_secret, + } + } + + fn visible_fields(&self) -> Vec { + let mut fields = vec![ + ConnectionFormField::ProfileName, + ConnectionFormField::DriverKind, + ]; + + match self.kind { + DatabaseKind::Sqlite => { + fields.push(ConnectionFormField::SqlitePath); + } + DatabaseKind::Postgres | DatabaseKind::Mysql => { + fields.extend([ + ConnectionFormField::Host, + ConnectionFormField::Port, + ConnectionFormField::Database, + ConnectionFormField::Username, + ConnectionFormField::PasswordEnvVar, + ConnectionFormField::SessionPassword, + ]); + } + } + + fields + } + + fn selected_field(&self) -> ConnectionFormField { + self.visible_fields() + .get(self.selected_field) + .copied() + .unwrap_or(ConnectionFormField::ProfileName) + } + + fn move_selection(&mut self, delta: isize) { + self.selected_field = cycle_index(self.selected_field, self.visible_fields().len(), delta); + } + + fn cycle_driver(&mut self, delta: isize) { + let kinds = [ + DatabaseKind::Sqlite, + DatabaseKind::Postgres, + DatabaseKind::Mysql, + ]; + let current = kinds + .iter() + .position(|kind| *kind == self.kind) + .unwrap_or(0); + let next = cycle_index(current, kinds.len(), delta); + self.kind = kinds[next]; + } + + fn selected_field_value_mut(&mut self) -> Option<&mut String> { + match self.selected_field() { + ConnectionFormField::ProfileName => Some(&mut self.name), + ConnectionFormField::DriverKind => None, + ConnectionFormField::SqlitePath => Some(&mut self.path), + ConnectionFormField::Host => Some(&mut self.host), + ConnectionFormField::Port => Some(&mut self.port), + ConnectionFormField::Database => Some(&mut self.database), + ConnectionFormField::Username => Some(&mut self.username), + ConnectionFormField::PasswordEnvVar => Some(&mut self.password_env_var), + ConnectionFormField::SessionPassword => Some(&mut self.session_password), + } + } + + fn display_value(&self, field: ConnectionFormField) -> String { + match field { + ConnectionFormField::ProfileName => self.name.clone(), + ConnectionFormField::DriverKind => self.kind.as_str().to_string(), + ConnectionFormField::SqlitePath => self.path.clone(), + ConnectionFormField::Host => self.host.clone(), + ConnectionFormField::Port => self.port.clone(), + ConnectionFormField::Database => self.database.clone(), + ConnectionFormField::Username => self.username.clone(), + ConnectionFormField::PasswordEnvVar => { + blank_to_dash(self.password_env_var.as_str()).to_string() + } + ConnectionFormField::SessionPassword => { + if !self.session_password.is_empty() { + format!( + "{} chars (session only)", + self.session_password.chars().count() + ) + } else if self.preserved_session_secret.is_some() { + String::from("[preserve current session secret]") + } else { + String::from("—") + } + } + } + } + + fn build_profile(&self) -> Result { + let target = match self.kind { + DatabaseKind::Sqlite => ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { + path: PathBuf::from(self.path.trim()), + }, + None, + None, + ), + DatabaseKind::Postgres | DatabaseKind::Mysql => { + let port = self + .port + .trim() + .parse::() + .map_err(|_| String::from("Port must be a valid non-zero integer."))?; + ConnectionTarget::new( + self.kind, + ConnectionTransport::Tcp { + host: self.host.trim().to_string(), + port, + }, + Some(self.database.trim().to_string()), + optional_string(self.username.as_str()), + ) + } + } + .map_err(|error| error.to_string())?; + + let mut profile = ConnectionProfile::new( + self.name.trim(), + target, + optional_string(self.password_env_var.as_str()), + ) + .map_err(|error| error.to_string())?; + + if !self.session_password.is_empty() { + profile = profile.with_password(Some(self.session_password.clone())); + } else if let Some(secret) = &self.preserved_session_secret { + profile = profile.with_password(Some(secret.clone())); + } + + Ok(profile) + } + + fn session_secret_input(&self, final_profile_name: &str) -> SessionSecretInput { + if !self.session_password.is_empty() { + SessionSecretInput::Set(self.session_password.clone()) + } else if self + .original_name + .as_deref() + .is_some_and(|original| original != final_profile_name) + { + self.preserved_session_secret + .clone() + .map(SessionSecretInput::Set) + .unwrap_or(SessionSecretInput::Preserve) + } else { + SessionSecretInput::Preserve + } } } @@ -595,9 +905,12 @@ struct App { view: WorkspaceView, focus: FocusArea, surface_state: SurfaceState, + profile_store: ConnectionProfileStore, + session_secrets: SessionSecretStore, connections: Vec, active_connection: usize, selected_connection: usize, + connection_workflow: ConnectionWorkflow, query_drafts: Vec, selected_query: usize, editor_mode: EditorMode, @@ -619,85 +932,56 @@ struct App { selected_result_row: usize, result_row_offset: usize, result_col_offset: usize, + connection_feedback: String, activity_message: String, } impl Default for App { fn default() -> Self { let (worker_tx, worker_rx) = mpsc::channel(); - let sqlite_profile = sqlite_profile(); - let postgres_profile = postgres_profile(); - let mysql_profile = mysql_profile(); - let connections = vec![ - ConnectionRecord { - profile: sqlite_profile.clone(), - summary: ConnectionSummary::from(&sqlite_profile), - 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, - expanded_schemas: vec![true, false], - selected_browser_row: 1, - details: &[ - "Single-file live demo backed by db-app and the SQLite fixture.", - "Best path for inspect → query → export in this runner.", - "Errors stay visible in the workspace.", - ], - failure_message: None, - }, - ConnectionRecord { - profile: postgres_profile.clone(), - summary: ConnectionSummary::from(&postgres_profile), - 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: &[ - "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: None, - }, - ConnectionRecord { - profile: mysql_profile.clone(), - summary: ConnectionSummary::from(&mysql_profile), - environment_label: "Docker demo", - database_label: "qa_demo", - 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: &[ - "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: None, - }, - ]; - - let query_drafts = build_query_drafts("main"); + let profile_store = ConnectionProfileStore::new(profile_store_path()); + let mut session_secrets = SessionSecretStore::default(); + let (catalog, bootstrap_message) = + bootstrap_profile_catalog(&profile_store, &mut session_secrets).unwrap_or_else( + |error| { + let mut catalog = ConnectionProfileCatalog::empty(); + for profile in default_profiles() { + let _ = catalog.upsert_profile(profile); + } + let _ = catalog.set_active_profile(Some("sqlite-local")); + ( + catalog, + format!( + "Profile store unavailable; using in-memory defaults. {}", + error.message + ), + ) + }, + ); + let active_profile_name = catalog + .active_profile + .clone() + .or_else(|| catalog.profiles.first().map(|profile| profile.name.clone())) + .unwrap_or_else(|| String::from("sqlite-local")); + let connections = build_connection_records(&catalog, &session_secrets, &[]); + let active_connection = connections + .iter() + .position(|record| record.profile.name == active_profile_name) + .unwrap_or(0); + let query_drafts = + build_query_drafts(connections[active_connection].query_qualifier.as_str()); Self { should_quit: false, view: WorkspaceView::Workspace, focus: FocusArea::Editor, surface_state: SurfaceState::Ready, + profile_store, + session_secrets, connections, - active_connection: 0, - selected_connection: 0, + active_connection, + selected_connection: active_connection, + connection_workflow: ConnectionWorkflow::Browsing, query_drafts, selected_query: 0, editor_mode: EditorMode::Navigate, @@ -719,9 +1003,8 @@ impl Default for App { selected_result_row: 0, result_row_offset: 0, result_col_offset: 0, - activity_message: String::from( - "SQLite workspace ready. [ ] switches drafts, Enter runs, x/j exports.", - ), + connection_feedback: format!("Profile store: {}", profile_store_path().display()), + activity_message: bootstrap_message, } } } @@ -732,6 +1015,10 @@ impl App { } fn handle_key(&mut self, key: KeyEvent) { + if self.handle_connection_workflow_key(key) { + return; + } + if self.editor_mode == EditorMode::Insert { self.handle_editor_insert_key(key); return; @@ -785,9 +1072,24 @@ impl App { self.update_surface_state(); self.activity_message = String::from("Execution state reset to ready."); } - KeyCode::Char('x') => self.start_export(ExportFormat::Csv), - KeyCode::Char('j') => self.start_export(ExportFormat::Json), - KeyCode::Char('i') if self.focus == FocusArea::Editor => { + KeyCode::Char('n') if self.view == WorkspaceView::Connections => { + self.begin_create_profile(); + } + KeyCode::Char('e') if self.view == WorkspaceView::Connections => { + self.begin_edit_profile(); + } + KeyCode::Char('d') if self.view == WorkspaceView::Connections => { + self.begin_delete_profile(); + } + KeyCode::Char('x') if self.view == WorkspaceView::Workspace => { + self.start_export(ExportFormat::Csv) + } + KeyCode::Char('j') if self.view == WorkspaceView::Workspace => { + self.start_export(ExportFormat::Json) + } + KeyCode::Char('i') + if self.focus == FocusArea::Editor && self.view == WorkspaceView::Workspace => + { self.editor_mode = EditorMode::Insert; self.activity_message = String::from("Editor insert mode enabled. Esc exits insert mode."); @@ -803,7 +1105,7 @@ impl App { .activate_browser_selection(); self.activity_message = message; self.update_surface_state(); - } else if self.focus == FocusArea::Editor { + } else if self.focus == FocusArea::Editor && self.view == WorkspaceView::Workspace { self.start_query_execution(); } } @@ -813,6 +1115,119 @@ impl App { } } + fn handle_connection_workflow_key(&mut self, key: KeyEvent) -> bool { + if self.view != WorkspaceView::Connections && !self.connection_workflow.is_modal() { + return false; + } + + if matches!(self.connection_workflow, ConnectionWorkflow::Editing(_)) + && self.focus != FocusArea::Editor + { + match key.code { + KeyCode::Up | KeyCode::Down | KeyCode::Enter => { + self.activity_message = String::from( + "Connection form is active. Move focus to Query Editor or press Esc to cancel.", + ); + return true; + } + _ => {} + } + } + + if self.editor_mode == EditorMode::Insert + && matches!(self.connection_workflow, ConnectionWorkflow::Editing(_)) + && self.focus == FocusArea::Editor + { + self.handle_connection_form_insert_key(key); + return true; + } + + match &self.connection_workflow { + ConnectionWorkflow::DeleteConfirm { .. } => match key.code { + KeyCode::Esc => { + self.connection_workflow = ConnectionWorkflow::Browsing; + self.activity_message = String::from( + "Delete confirmation cancelled. Connection list is unchanged.", + ); + return true; + } + KeyCode::Enter => { + self.confirm_delete_profile(); + return true; + } + _ => return false, + }, + ConnectionWorkflow::Editing(_) => match key.code { + KeyCode::Esc => { + self.connection_workflow = ConnectionWorkflow::Browsing; + self.editor_mode = EditorMode::Navigate; + self.activity_message = + String::from("Connection form cancelled. Saved profiles remain unchanged."); + return true; + } + KeyCode::Char('t') => { + self.test_connection_form(); + return true; + } + KeyCode::Char('s') => { + self.save_connection_form_and_activate(); + return true; + } + KeyCode::Up if self.focus == FocusArea::Editor => { + if let ConnectionWorkflow::Editing(form) = &mut self.connection_workflow { + form.move_selection(-1); + self.activity_message = + format!("Editing {}.", form.selected_field().label()); + } + return true; + } + KeyCode::Down if self.focus == FocusArea::Editor => { + if let ConnectionWorkflow::Editing(form) = &mut self.connection_workflow { + form.move_selection(1); + self.activity_message = + format!("Editing {}.", form.selected_field().label()); + } + return true; + } + KeyCode::Enter if self.focus == FocusArea::Editor => { + if let ConnectionWorkflow::Editing(form) = &mut self.connection_workflow { + if form.selected_field() == ConnectionFormField::DriverKind { + form.cycle_driver(1); + form.selected_field = form + .selected_field + .min(form.visible_fields().len().saturating_sub(1)); + self.activity_message = + format!("Driver changed to {}.", form.kind.as_str()); + } else if form.selected_field().accepts_text() { + self.editor_mode = EditorMode::Insert; + self.activity_message = format!( + "Editing {}. Enter commits the field, Esc exits editing.", + form.selected_field().label() + ); + } + } + return true; + } + KeyCode::Char('i') if self.focus == FocusArea::Editor => { + if let ConnectionWorkflow::Editing(form) = &mut self.connection_workflow { + if form.selected_field().accepts_text() { + self.editor_mode = EditorMode::Insert; + self.activity_message = format!( + "Editing {}. Enter commits the field, Esc exits editing.", + form.selected_field().label() + ); + } + } + return true; + } + _ => return false, + }, + ConnectionWorkflow::Browsing => {} + } + + false + } + fn handle_editor_insert_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -837,6 +1252,46 @@ impl App { } } + fn handle_connection_form_insert_key(&mut self, key: KeyEvent) { + let ConnectionWorkflow::Editing(form) = &mut self.connection_workflow else { + return; + }; + + match key.code { + KeyCode::Esc => { + self.editor_mode = EditorMode::Navigate; + self.activity_message = format!( + "{} edit committed. Press t to test or s to save and activate.", + form.selected_field().label() + ); + } + KeyCode::Backspace => { + if let Some(value) = form.selected_field_value_mut() { + value.pop(); + self.activity_message = format!("Updated {}.", form.selected_field().label()); + } + } + KeyCode::Enter => { + let previous_field = form.selected_field(); + self.editor_mode = EditorMode::Navigate; + form.move_selection(1); + self.activity_message = format!( + "{} saved in the draft. Next: {}.", + previous_field.label(), + form.selected_field().label() + ); + } + KeyCode::Char(ch) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + if let Some(value) = form.selected_field_value_mut() { + value.push(ch); + } + } + _ => {} + } + } + fn tick(&mut self) { while let Ok(message) = self.worker_rx.try_recv() { match message { @@ -874,6 +1329,10 @@ impl App { } fn handle_bracket_key(&mut self, delta: isize) { + if self.view != WorkspaceView::Workspace { + return; + } + match self.focus { FocusArea::Editor => { self.selected_query = @@ -956,7 +1415,234 @@ impl App { } fn activate_selected_connection(&mut self) { - self.begin_connection_load(self.selected_connection, false); + let profile_name = self.selected_connection_record().profile.name.clone(); + + match db_app::activate_profile(&self.profile_store, &self.session_secrets, &profile_name) { + Ok(_) => { + self.connection_feedback = format!("Active profile set to {}.", profile_name); + self.begin_connection_load(self.selected_connection, false); + } + Err(error) => { + self.connection_feedback = error.message.clone(); + self.activity_message = + format!("Failed to activate {}: {}", profile_name, error.message); + } + } + } + + fn begin_create_profile(&mut self) { + let mut form = ConnectionFormState::new_create(); + form.name = self.suggest_profile_name("sqlite-local-copy"); + self.view = WorkspaceView::Connections; + self.focus = FocusArea::Editor; + self.editor_mode = EditorMode::Navigate; + self.connection_workflow = ConnectionWorkflow::Editing(form); + self.activity_message = + String::from("Create profile started. Use Up/Down to choose a field, i to edit."); + } + + fn begin_edit_profile(&mut self) { + let record = self.selected_connection_record().clone(); + self.view = WorkspaceView::Connections; + self.focus = FocusArea::Editor; + self.editor_mode = EditorMode::Navigate; + self.connection_workflow = ConnectionWorkflow::Editing(ConnectionFormState::from_record( + &record, + &self.session_secrets, + )); + self.activity_message = format!( + "Editing {}. Use t to test or s to save and activate.", + record.profile.name + ); + } + + fn begin_delete_profile(&mut self) { + if self.connections.len() <= 1 { + self.activity_message = + String::from("Keep at least one saved profile in the TUI workspace."); + return; + } + + let profile_name = self.selected_connection_record().profile.name.clone(); + self.connection_workflow = ConnectionWorkflow::DeleteConfirm { + profile_name: profile_name.clone(), + }; + self.activity_message = format!( + "Delete confirmation opened for {}. Press Enter to confirm or Esc to cancel.", + profile_name + ); + } + + fn test_connection_form(&mut self) { + let form = match &self.connection_workflow { + ConnectionWorkflow::Editing(form) => form.clone(), + _ => return, + }; + + let profile = match form.build_profile() { + Ok(profile) => profile, + Err(message) => { + self.connection_feedback = message.clone(); + self.activity_message = message; + return; + } + }; + + let profile_name = profile.name.clone(); + match load_connection_context(&profile) { + Ok(result) => { + self.connection_feedback = format!( + "Test succeeded for {}. Loaded {} schema entries.", + profile_name, + result.browser_schemas.len() + ); + self.activity_message = String::from( + "Connection test succeeded. Press s to save and activate this profile.", + ); + } + Err(error) => { + self.connection_feedback = error.message.clone(); + self.activity_message = format!( + "Connection test failed for {}: {}", + profile_name, error.message + ); + } + } + } + + fn save_connection_form_and_activate(&mut self) { + let form = match &self.connection_workflow { + ConnectionWorkflow::Editing(form) => form.clone(), + _ => return, + }; + + let profile = match form.build_profile() { + Ok(profile) => profile, + Err(message) => { + self.connection_feedback = message.clone(); + self.activity_message = message; + return; + } + }; + + let original_name = form.original_name.clone(); + let session_secret = form.session_secret_input(profile.name.as_str()); + let profile_name = profile.name.clone(); + + if let Err(error) = db_app::save_profile( + &self.profile_store, + &mut self.session_secrets, + SaveProfileRequest { + profile, + set_active: true, + session_secret, + }, + ) { + self.connection_feedback = error.message.clone(); + self.activity_message = format!("Failed to save {}: {}", profile_name, error.message); + return; + } + + if original_name + .as_deref() + .is_some_and(|original| original != profile_name) + { + if let Some(original_name) = original_name.as_deref() { + if let Err(error) = db_app::delete_profile( + &self.profile_store, + &mut self.session_secrets, + original_name, + ) { + self.connection_feedback = error.message.clone(); + self.activity_message = format!( + "Saved {}, but failed to remove old profile {}: {}", + profile_name, original_name, error.message + ); + return; + } + } + } + + match self.sync_connections_from_store(Some(profile_name.as_str())) { + Ok(connection_index) => { + self.connection_workflow = ConnectionWorkflow::Browsing; + self.editor_mode = EditorMode::Navigate; + self.connection_feedback = format!( + "Saved {} to {} and set it active.", + profile_name, + self.profile_store.path().display() + ); + self.begin_connection_load(connection_index, false); + } + Err(error) => { + self.connection_feedback = error.message.clone(); + self.activity_message = format!( + "Saved {}, but failed to refresh the catalog: {}", + profile_name, error.message + ); + } + } + } + + fn confirm_delete_profile(&mut self) { + let profile_name = match &self.connection_workflow { + ConnectionWorkflow::DeleteConfirm { profile_name } => profile_name.clone(), + _ => return, + }; + let deleting_active = self.active_connection_record().profile.name == profile_name; + + if let Err(error) = db_app::delete_profile( + &self.profile_store, + &mut self.session_secrets, + profile_name.as_str(), + ) { + self.connection_feedback = error.message.clone(); + self.activity_message = format!("Failed to delete {}: {}", profile_name, error.message); + return; + } + + self.connection_workflow = ConnectionWorkflow::Browsing; + + if deleting_active { + match self.sync_connections_from_store(None) { + Ok(connection_index) => { + let fallback_name = self.connections[connection_index].profile.name.clone(); + if let Err(error) = db_app::activate_profile( + &self.profile_store, + &self.session_secrets, + fallback_name.as_str(), + ) { + self.connection_feedback = error.message.clone(); + self.activity_message = format!( + "Deleted {}, but failed to activate fallback profile {}: {}", + profile_name, fallback_name, error.message + ); + return; + } + + let _ = self.sync_connections_from_store(Some(fallback_name.as_str())); + self.connection_feedback = format!( + "Deleted {}. Activated fallback profile {}.", + profile_name, fallback_name + ); + self.begin_connection_load(connection_index, false); + } + Err(error) => { + self.connection_feedback = error.message.clone(); + self.activity_message = format!( + "Deleted {}, but failed to refresh the remaining profile list: {}", + profile_name, error.message + ); + } + } + } else if let Ok(connection_index) = self.sync_connections_from_store(None) { + self.selected_connection = + connection_index.min(self.connections.len().saturating_sub(1)); + self.connection_feedback = + format!("Deleted {} from the saved profile list.", profile_name); + self.activity_message = + String::from("Saved profile deleted. Press Enter on a target to activate it."); + } } fn start_query_execution(&mut self) { @@ -995,11 +1681,13 @@ impl App { } if self.active_connection_record().selected_schema().state == SchemaState::Restricted { + let schema = self.active_connection_record().selected_schema(); self.last_query_error = Some(AppError { operation: "query", kind: AppErrorKind::Inspect, - message: String::from( - "Query execution blocked because the selected schema is restricted.", + message: format!( + "Query execution blocked because schema {} is restricted. {}", + schema.name, schema.message ), target: Some(self.active_connection_record().summary.clone()), }); @@ -1033,7 +1721,9 @@ impl App { } }; - let profile = self.active_connection_record().runtime_profile(); + let profile = self + .active_connection_record() + .runtime_profile(&self.session_secrets); let sender = self.worker_tx.clone(); let request_for_worker = request.clone(); let run_id = self.next_run_id; @@ -1090,7 +1780,9 @@ impl App { }; let export_request = self.default_export_request(format); - let profile = self.active_connection_record().runtime_profile(); + let profile = self + .active_connection_record() + .runtime_profile(&self.session_secrets); let sender = self.worker_tx.clone(); let run_id = self.next_run_id; self.next_run_id += 1; @@ -1183,8 +1875,11 @@ 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.query_drafts = + build_query_drafts(self.connections[connection_index].query_qualifier.as_str()); + 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; @@ -1199,7 +1894,7 @@ impl App { record.expanded_schemas = vec![true]; record.selected_browser_row = 0; - let profile = record.runtime_profile(); + let profile = record.runtime_profile(&self.session_secrets); let sender = self.worker_tx.clone(); let profile_name = record.summary.profile_name.clone(); self.update_surface_state(); @@ -1406,6 +2101,7 @@ impl App { record.summary.profile_name, record.browser_schemas.len() ); + self.connection_feedback = self.activity_message.clone(); } Err(error) => { record.status = ConnectionHealth::Failed; @@ -1422,6 +2118,7 @@ impl App { "Failed to activate {}: {}", record.summary.profile_name, error.message ); + self.connection_feedback = self.activity_message.clone(); } } @@ -1468,6 +2165,64 @@ impl App { overwrite: true, } } + + fn suggest_profile_name(&self, base: &str) -> String { + if !self + .connections + .iter() + .any(|record| record.profile.name == base) + { + return base.to_string(); + } + + let mut suffix = 2; + loop { + let candidate = format!("{base}-{suffix}"); + if !self + .connections + .iter() + .any(|record| record.profile.name == candidate) + { + return candidate; + } + suffix += 1; + } + } + + fn sync_connections_from_store( + &mut self, + preferred_active_name: Option<&str>, + ) -> Result { + let catalog = self.load_profile_catalog()?; + self.connections = + build_connection_records(&catalog, &self.session_secrets, &self.connections); + + let active_name = preferred_active_name + .map(String::from) + .or_else(|| catalog.active_profile.clone()) + .or_else(|| { + self.connections + .first() + .map(|record| record.profile.name.clone()) + }) + .unwrap_or_else(|| String::from("sqlite-local")); + + let index = self + .connections + .iter() + .position(|record| record.profile.name == active_name) + .unwrap_or(0); + + self.active_connection = index; + self.selected_connection = index; + Ok(index) + } + + fn load_profile_catalog(&self) -> Result { + self.profile_store + .load() + .map_err(|error| config_app_error("load_profiles", None, error)) + } } const RESULT_ROW_WINDOW: usize = 5; @@ -1839,6 +2594,11 @@ fn build_browser_schemas( let mut schemas = Vec::new(); for schema_item in schema_items { + if let Some(schema) = restricted_browser_schema_from_root_item(&schema_item) { + schemas.push(schema); + continue; + } + let request = InspectRequest { schema: Some(schema_item.name.clone()), table: None, @@ -1937,6 +2697,26 @@ fn build_browser_schemas( Ok(leak_schemas(schemas)) } +fn restricted_browser_schema_from_root_item(schema_item: &SchemaItem) -> Option { + if schema_item.availability != AppSchemaAvailability::Restricted { + return None; + } + + let message = schema_item.note.clone().unwrap_or_else(|| { + format!( + "Schema {} is restricted in the current target.", + schema_item.name + ) + }); + + Some(BrowserSchema { + name: leak_str(schema_item.name.clone()), + state: SchemaState::Restricted, + objects: leak_objects(Vec::new()), + message: leak_str(message), + }) +} + fn loading_browser_schemas() -> &'static [BrowserSchema] { leak_schemas(vec![BrowserSchema { name: leak_str("loading"), @@ -1996,6 +2776,304 @@ fn app_error_kind_label(kind: &AppErrorKind) -> &'static str { AppErrorKind::Inspect => "Inspect", AppErrorKind::Query => "Query", AppErrorKind::Export => "Export", + AppErrorKind::Config => "Config", + } +} + +fn config_app_error( + operation: &'static str, + profile: Option<&ConnectionProfile>, + error: ConfigError, +) -> AppError { + AppError { + operation, + kind: AppErrorKind::Config, + message: error.to_string(), + target: profile.map(ConnectionSummary::from), + } +} + +fn blank_to_dash(value: &str) -> &str { + if value.trim().is_empty() { + "—" + } else { + value + } +} + +fn secret_source_label(source: SecretSource) -> &'static str { + match source { + SecretSource::None => "none", + SecretSource::Session => "session", + SecretSource::EnvVar => "env var", + } +} + +fn optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn profile_store_path() -> PathBuf { + if let Ok(path) = env::var("DBTOOL_TUI_PROFILE_STORE") { + if !path.trim().is_empty() { + return PathBuf::from(path); + } + } + + if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { + if !xdg_config_home.trim().is_empty() { + return PathBuf::from(xdg_config_home) + .join("dbtool") + .join("tui-profiles.json"); + } + } + + if let Ok(home) = env::var("HOME") { + if !home.trim().is_empty() { + return PathBuf::from(home) + .join(".config") + .join("dbtool") + .join("tui-profiles.json"); + } + } + + env::temp_dir().join("dbtool-tui-profiles.json") +} + +fn default_profiles() -> Vec { + vec![sqlite_profile(), postgres_profile(), mysql_profile()] +} + +fn bootstrap_profile_catalog( + store: &ConnectionProfileStore, + secrets: &mut SessionSecretStore, +) -> Result<(ConnectionProfileCatalog, String), AppError> { + let existing = store + .load() + .map_err(|error| config_app_error("load_profiles", None, error))?; + + if !existing.profiles.is_empty() { + let active_name = existing + .active_profile + .clone() + .or_else(|| { + existing + .profiles + .first() + .map(|profile| profile.name.clone()) + }) + .unwrap_or_else(|| String::from("none")); + return Ok(( + existing, + format!( + "Loaded saved profiles from {}. Active target: {}.", + store.path().display(), + active_name + ), + )); + } + + for (index, profile) in default_profiles().into_iter().enumerate() { + db_app::save_profile( + store, + secrets, + SaveProfileRequest { + profile, + set_active: index == 0, + session_secret: SessionSecretInput::Preserve, + }, + )?; + } + + let catalog = store + .load() + .map_err(|error| config_app_error("load_profiles", None, error))?; + + Ok(( + catalog, + format!( + "Seeded default profiles at {}. Press 2 for Connections to add or edit targets.", + store.path().display() + ), + )) +} + +fn build_connection_records( + catalog: &ConnectionProfileCatalog, + secrets: &SessionSecretStore, + existing: &[ConnectionRecord], +) -> Vec { + catalog + .profiles + .iter() + .map(|profile| { + let secret_source = profile_secret_source(profile, secrets); + let query_qualifier = connection_query_qualifier(profile); + let validation_label = connection_validation_label(profile, secret_source); + let preview_schemas = connection_preview_schemas(profile); + let mut record = existing + .iter() + .find(|current| current.profile.name == profile.name) + .cloned() + .unwrap_or_else(|| ConnectionRecord { + profile: profile.clone(), + summary: ConnectionSummary::from(profile), + secret_source, + environment_label: connection_environment_label(profile), + database_label: connection_database_label(profile), + mode_label: connection_mode_label(profile, secret_source), + query_qualifier: query_qualifier.clone(), + validation_label: validation_label.clone(), + status: ConnectionHealth::Ready, + browser_schemas: preview_schemas, + expanded_schemas: vec![true; preview_schemas.len()], + selected_browser_row: 0, + details: connection_details(profile, secret_source), + failure_message: None, + }); + + record.profile = profile.clone(); + record.summary = ConnectionSummary::from(profile); + record.secret_source = secret_source; + record.environment_label = connection_environment_label(profile); + record.database_label = connection_database_label(profile); + record.mode_label = connection_mode_label(profile, secret_source); + record.query_qualifier = query_qualifier; + if record.status == ConnectionHealth::Ready { + record.validation_label = validation_label; + } + record.details = connection_details(profile, secret_source); + if record.expanded_schemas.len() != record.browser_schemas.len() { + record.expanded_schemas = vec![true; record.browser_schemas.len()]; + } + record.clamp_browser_selection(); + record + }) + .collect() +} + +fn profile_secret_source( + profile: &ConnectionProfile, + secrets: &SessionSecretStore, +) -> SecretSource { + if secrets.contains(profile.name.as_str()) { + SecretSource::Session + } else if profile.password_env_var.is_some() { + SecretSource::EnvVar + } else { + SecretSource::None + } +} + +fn connection_environment_label(profile: &ConnectionProfile) -> String { + match &profile.target.transport { + ConnectionTransport::File { .. } => String::from("Local file"), + ConnectionTransport::Tcp { host, .. } if host == "host.docker.internal" => { + String::from("Docker demo") + } + ConnectionTransport::Tcp { host, .. } if host == "127.0.0.1" || host == "localhost" => { + String::from("Local network") + } + ConnectionTransport::Tcp { .. } => String::from("Saved target"), + } +} + +fn connection_database_label(profile: &ConnectionProfile) -> String { + match &profile.target.transport { + ConnectionTransport::File { path } => path.display().to_string(), + ConnectionTransport::Tcp { .. } => profile + .target + .database + .clone() + .unwrap_or_else(|| String::from("default")), + } +} + +fn connection_mode_label(profile: &ConnectionProfile, secret_source: SecretSource) -> String { + match profile.target.kind { + DatabaseKind::Sqlite => String::from("Local verify"), + DatabaseKind::Postgres | DatabaseKind::Mysql => match secret_source { + SecretSource::Session => String::from("Session secret"), + SecretSource::EnvVar => String::from("Env secret"), + SecretSource::None => String::from("Saved target"), + }, + } +} + +fn connection_query_qualifier(profile: &ConnectionProfile) -> String { + match profile.name.as_str() { + "reporting-postgres" | "orders-mysql" => String::from("qa_demo"), + _ => match profile.target.kind { + DatabaseKind::Sqlite => String::from("main"), + DatabaseKind::Postgres => String::from("public"), + DatabaseKind::Mysql => profile + .target + .database + .clone() + .unwrap_or_else(|| String::from("qa_demo")), + }, + } +} + +fn connection_validation_label(profile: &ConnectionProfile, secret_source: SecretSource) -> String { + match profile.target.kind { + DatabaseKind::Sqlite => String::from("Saved local target"), + DatabaseKind::Postgres | DatabaseKind::Mysql => match secret_source { + SecretSource::Session => String::from("Saved target · session secret ready"), + SecretSource::EnvVar => String::from("Saved target · env secret fallback"), + SecretSource::None => { + String::from("Saved target · add a session secret before testing") + } + }, + } +} + +fn connection_details(profile: &ConnectionProfile, secret_source: SecretSource) -> Vec { + let secret_copy = match secret_source { + SecretSource::Session => String::from("Password comes from the current TUI session only."), + SecretSource::EnvVar => profile + .password_env_var + .as_ref() + .map(|env_name| format!("Password falls back to env var {}.", env_name)) + .unwrap_or_else(|| String::from("No secret source is configured.")), + SecretSource::None => String::from( + "No secret is configured yet; test may fail until a session password is set.", + ), + }; + + match &profile.target.transport { + ConnectionTransport::File { path } => vec![ + format!("SQLite target at {}.", path.display()), + String::from("Best path for local inspect → query → export rehearsal."), + secret_copy, + ], + ConnectionTransport::Tcp { host, port } => vec![ + format!( + "{} server target at {}:{}.", + profile.target.kind.as_str(), + host, + port + ), + format!( + "Database: {}.", + profile.target.database.as_deref().unwrap_or("default") + ), + secret_copy, + ], + } +} + +fn connection_preview_schemas(profile: &ConnectionProfile) -> &'static [BrowserSchema] { + match profile.name.as_str() { + "sqlite-local" => &SQLITE_SCHEMAS, + "reporting-postgres" => &POSTGRES_SCHEMAS, + "orders-mysql" => &MYSQL_SCHEMAS, + _ => empty_browser_schemas("Activate this saved profile to load live schema metadata."), } } @@ -2279,25 +3357,49 @@ fn render_body(frame: &mut Frame, area: Rect, app: &App) { } fn render_footer(frame: &mut Frame, area: Rect, app: &App) { - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Tab/Shift+Tab", Style::default().fg(Color::White)), - Span::raw(" focus "), - Span::styled("↑/↓", Style::default().fg(Color::White)), - Span::raw(" move "), - Span::styled("Enter", Style::default().fg(Color::White)), - Span::raw(" run/activate "), - Span::styled("i", Style::default().fg(Color::White)), - Span::raw(" insert "), - Span::styled("x/j", Style::default().fg(Color::White)), - Span::raw(" export csv/json "), - Span::styled("[ ]", Style::default().fg(Color::White)), - Span::raw(" query/columns "), - Span::styled("Esc", Style::default().fg(Color::White)), - Span::raw(" back "), - Span::styled("q", Style::default().fg(Color::White)), - Span::raw(" quit"), - ])) - .style(Style::default().fg(Color::Gray)); + let footer_items = if app.view == WorkspaceView::Connections { + vec![ + Span::styled("Tab/Shift+Tab", Style::default().fg(Color::White)), + Span::raw(" focus "), + Span::styled("↑/↓", Style::default().fg(Color::White)), + Span::raw(" list/field "), + Span::styled("n/e/d", Style::default().fg(Color::White)), + Span::raw(" new/edit/delete "), + Span::styled("i", Style::default().fg(Color::White)), + Span::raw(" edit field "), + Span::styled("t", Style::default().fg(Color::White)), + Span::raw(" test "), + Span::styled("s", Style::default().fg(Color::White)), + Span::raw(" save+activate "), + Span::styled("Enter", Style::default().fg(Color::White)), + Span::raw(" activate/cycle "), + Span::styled("Esc", Style::default().fg(Color::White)), + Span::raw(" cancel "), + Span::styled("q", Style::default().fg(Color::White)), + Span::raw(" quit"), + ] + } else { + vec![ + Span::styled("Tab/Shift+Tab", Style::default().fg(Color::White)), + Span::raw(" focus "), + Span::styled("↑/↓", Style::default().fg(Color::White)), + Span::raw(" move "), + Span::styled("Enter", Style::default().fg(Color::White)), + Span::raw(" run/activate "), + Span::styled("i", Style::default().fg(Color::White)), + Span::raw(" insert "), + Span::styled("x/j", Style::default().fg(Color::White)), + Span::raw(" export csv/json "), + Span::styled("[ ]", Style::default().fg(Color::White)), + Span::raw(" query/columns "), + Span::styled("Esc", Style::default().fg(Color::White)), + Span::raw(" back "), + Span::styled("q", Style::default().fg(Color::White)), + Span::raw(" quit"), + ] + }; + + let footer = Paragraph::new(Line::from(footer_items)).style(Style::default().fg(Color::Gray)); let footer_block = Block::default() .borders(Borders::TOP) @@ -2317,9 +3419,9 @@ fn render_connections(frame: &mut Frame, area: Rect, app: &App) { .enumerate() .map(|(index, connection)| { let marker = if index == app.active_connection { - "Current" + "Active" } else { - "Available" + "Saved" }; let style = if index == app.selected_connection { @@ -2357,6 +3459,10 @@ fn render_connections(frame: &mut Frame, area: Rect, app: &App) { format!("Validation: {}", connection.validation_label), detail_style, )), + Line::from(Span::styled( + format!("Secret: {}", secret_source_label(connection.secret_source)), + detail_style, + )), ])) }); @@ -2528,30 +3634,91 @@ fn render_editor(frame: &mut Frame, area: Rect, app: &App) { } } } - WorkspaceView::Connections => { - lines.push(Line::from(format!( - "Selected profile: {}", - 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!("Database: {}", selected.database_label))); - lines.push(Line::from(format!("Mode: {}", selected.mode_label))); - lines.push(Line::from(format!( - "Validation: {}", - selected.validation_label - ))); - } + WorkspaceView::Connections => match &app.connection_workflow { + ConnectionWorkflow::Browsing => { + lines.push(Line::from(format!( + "Selected profile: {}", + 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!("Database: {}", selected.database_label))); + lines.push(Line::from(format!("Mode: {}", selected.mode_label))); + lines.push(Line::from(format!( + "Secret source: {}", + secret_source_label(selected.secret_source) + ))); + lines.push(Line::from("")); + lines.push(Line::from("Actions")); + lines.push(Line::from("- n creates a new saved profile")); + lines.push(Line::from("- e edits the selected saved profile")); + lines.push(Line::from("- d deletes the selected saved profile")); + lines.push(Line::from( + "- Enter persists activation and opens the workspace", + )); + } + ConnectionWorkflow::Editing(form) => { + let fields = form.visible_fields(); + lines.push(Line::from(format!( + "{} · step {} of {}", + form.purpose.label(), + form.selected_field + 1, + fields.len() + ))); + lines.push(Line::from("")); + for (index, field) in fields.iter().enumerate() { + let prefix = if index == form.selected_field { + "→" + } else { + " " + }; + let value = form.display_value(*field); + lines.push(Line::from(format!( + "{} {:<16} {}", + prefix, + field.label(), + value + ))); + if index == form.selected_field { + lines.push(Line::from(format!(" {}", field.hint()))); + } + } + lines.push(Line::from("")); + lines.push(Line::from("Actions")); + lines.push(Line::from("- i edits the selected field")); + lines.push(Line::from("- Enter cycles driver or commits field editing")); + lines.push(Line::from("- t tests the staged connection")); + lines.push(Line::from("- s saves and activates the staged profile")); + } + ConnectionWorkflow::DeleteConfirm { profile_name } => { + lines.push(Line::from("Delete Profile")); + lines.push(Line::from("")); + lines.push(Line::from(format!("Target: {}", profile_name))); + lines.push(Line::from(format!( + "Current active target: {}", + app.active_connection_record().summary.profile_name + ))); + lines.push(Line::from("")); + lines.push(Line::from("Press Enter to confirm deletion.")); + lines.push(Line::from("Press Esc to keep the saved profile.")); + } + }, WorkspaceView::Help => { lines.push(Line::from("Query Workflow")); lines.push(Line::from("")); - lines.push(Line::from("1. [ ] switches drafts.")); - lines.push(Line::from("2. Enter runs the current draft.")); - lines.push(Line::from("3. Press i to edit; Esc leaves edit mode.")); - lines.push(Line::from("4. Press x or j to export the current result.")); + lines.push(Line::from( + "1. Press 2 for Connections and use n/e/d to manage profiles.", + )); + lines.push(Line::from( + "2. Enter activates a saved profile and loads live schema.", + )); + lines.push(Line::from("3. [ ] switches drafts in Workspace view.")); + lines.push(Line::from( + "4. Enter runs the current draft; x/j export the result.", + )); } } @@ -2569,12 +3736,12 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) { match app.view { WorkspaceView::Help => { let widget = Paragraph::new(Text::from(vec![ - Line::from("Query QA"), + Line::from("Connection + Query QA"), Line::from(""), - Line::from("1. Run success, empty, and error drafts."), - Line::from("2. Verify each state stays visible."), - Line::from("3. Use [ ] to page wide columns."), - Line::from("4. Use ↑/↓ to browse result rows."), + Line::from("1. In Connections view, create or edit a profile and test it."), + Line::from("2. Activate the profile and verify connect + inspect feedback."), + Line::from("3. Run success, empty, and error drafts."), + Line::from("4. Use [ ] to page wide columns and ↑/↓ to browse rows."), ])) .block(panel_block("Results", app.focus == FocusArea::Results)) .wrap(Wrap { trim: true }); @@ -2582,17 +3749,42 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) { frame.render_widget(widget, area); } WorkspaceView::Connections => { - let widget = Paragraph::new(Text::from(vec![ + let mut lines = vec![ Line::from(format!("Current target: {}", active.summary.profile_name)), Line::from(""), - Line::from("Execution workflow"), - Line::from("- Enter runs the current query"), - Line::from("- Running, empty, and error states stay visible"), - Line::from("- x and j export the current result"), - Line::from("- Results supports row and column paging"), - ])) - .block(panel_block("Results", app.focus == FocusArea::Results)) - .wrap(Wrap { trim: true }); + Line::from("Connection management"), + Line::from(app.connection_feedback.clone()), + Line::from(""), + ]; + + match &app.connection_workflow { + ConnectionWorkflow::Browsing => { + lines.push(Line::from("Review")); + lines.push(Line::from( + "- Saved targets live on disk via the shared profile store", + )); + lines.push(Line::from( + "- Passwords remain session-only unless an env var fallback is set", + )); + lines.push(Line::from("- Activate a saved target with Enter")); + } + ConnectionWorkflow::Editing(_) => { + lines.push(Line::from("Happy path")); + lines.push(Line::from("- Fill required fields in Query Editor")); + lines.push(Line::from("- Press t to verify connect + inspect")); + lines.push(Line::from("- Press s to save and activate the target")); + } + ConnectionWorkflow::DeleteConfirm { .. } => { + lines.push(Line::from("Deletion path")); + lines.push(Line::from("- Enter confirms profile removal")); + lines.push(Line::from("- Esc cancels and keeps the saved profile")); + lines.push(Line::from("- The last remaining profile cannot be deleted")); + } + } + + let widget = Paragraph::new(Text::from(lines)) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); frame.render_widget(widget, area); } @@ -2781,6 +3973,7 @@ fn render_inspector(frame: &mut Frame, area: Rect, app: &App) { Line::from(format!("Profile: {}", active.summary.profile_name)), Line::from(format!("Schema: {}", schema.name)), Line::from(format!("Schema state: {}", schema.state.label())), + Line::from(format!("Schema detail: {}", schema.message)), Line::from(format!("Query draft: {}", draft.name)), Line::from(format!("Execution: {}", app.query_run_state.label())), Line::from(format!("Export: {}", app.export_run_state.label())), @@ -2851,12 +4044,21 @@ fn render_inspector(frame: &mut Frame, area: Rect, app: &App) { Line::from(""), Line::from(format!("Selected: {}", selected.summary.profile_name)), Line::from(format!("Selected state: {}", selected.status.label())), + Line::from(format!( + "Selected secret: {}", + secret_source_label(selected.secret_source) + )), Line::from(format!("Current: {}", active.summary.profile_name)), Line::from(format!("Current state: {}", active.status.label())), Line::from(""), ]; - lines.extend(selected.details.iter().map(|line| Line::from(*line))); + lines.extend( + selected + .details + .iter() + .map(|line| Line::from(line.as_str())), + ); if let Some(message) = selected.failure_message.as_deref() { lines.push(Line::from("")); @@ -2869,11 +4071,11 @@ fn render_inspector(frame: &mut Frame, area: Rect, app: &App) { WorkspaceView::Help => vec![ Line::from("Navigation Model"), Line::from(""), - Line::from("- Enter runs from Editor focus"), - Line::from("- i edits; Esc exits edit mode"), + Line::from("- 2 opens Connections; n/e/d manage saved profiles"), + Line::from("- i edits the current field or SQL buffer"), + Line::from("- t tests a staged connection; s saves and activates it"), + Line::from("- Enter activates saved targets or runs the current draft"), Line::from("- [ ] switches drafts or pages wide results"), - Line::from("- x/j exports the current result"), - Line::from("- ↑/↓ browses result rows"), ], }; @@ -2916,7 +4118,7 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) { ]); let mut lines = vec![ - banner, + banner.clone(), Line::from(""), Line::from(format!("Draft: {}", draft.name)), Line::from(app.query_execution_copy()), @@ -2933,6 +4135,32 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) { lines.insert(4, Line::from(app.export_execution_copy())); } + if app.view == WorkspaceView::Connections { + lines = vec![ + banner, + Line::from(""), + Line::from(format!( + "Profile store: {}", + app.profile_store.path().display() + )), + Line::from(format!("Current target: {}", active.summary.profile_name)), + Line::from(app.connection_feedback.clone()), + Line::from(""), + Line::from("Connection workflow"), + Line::from(match &app.connection_workflow { + ConnectionWorkflow::Browsing => { + "Browse saved profiles, then activate one with Enter." + } + ConnectionWorkflow::Editing(_) => { + "The staged form is active. Test before saving and activating." + } + ConnectionWorkflow::DeleteConfirm { .. } => { + "Deletion confirmation is active. Enter confirms, Esc cancels." + } + }), + ]; + } + if let Some(message) = active.failure_message.as_deref() { lines.push(Line::from("")); lines.push(Line::from("Recovery path")); @@ -3143,4 +4371,110 @@ mod tests { assert!(drafts[0].sql.contains("left join qa_demo.tickets t")); assert!(drafts[1].sql.contains("from qa_demo.tickets t")); } + + #[test] + fn restricted_root_schema_item_maps_directly_to_browser_schema() { + let schema = restricted_browser_schema_from_root_item(&SchemaItem { + name: String::from("restricted_probe"), + availability: AppSchemaAvailability::Restricted, + note: Some(String::from( + "schema access is restricted: restricted_probe", + )), + }) + .expect("restricted schema item should map directly"); + + assert_eq!(schema.name, "restricted_probe"); + assert_eq!(schema.state, SchemaState::Restricted); + assert_eq!(schema.objects.len(), 0); + assert_eq!( + schema.message, + "schema access is restricted: restricted_probe" + ); + } + + #[test] + fn ready_root_schema_item_still_requires_followup_inspect() { + let schema = restricted_browser_schema_from_root_item(&SchemaItem { + name: String::from("public"), + availability: AppSchemaAvailability::Ready, + note: None, + }); + + assert!(schema.is_none()); + } + + #[test] + fn restricted_schema_blocks_query_with_schema_specific_message() { + let mut app = App::default(); + app.connections[0].browser_schemas = leak_schemas(vec![BrowserSchema { + name: leak_str("restricted_probe"), + state: SchemaState::Restricted, + objects: leak_objects(Vec::new()), + message: leak_str("schema access is restricted: restricted_probe"), + }]); + app.connections[0].expanded_schemas = vec![false]; + app.connections[0].selected_browser_row = 0; + + app.start_query_execution(); + + assert_eq!(app.query_run_state, QueryRunState::Error); + assert!(app + .last_query_error + .as_ref() + .expect("restricted schema should produce query error") + .message + .contains("restricted_probe")); + assert!(app + .last_query_error + .as_ref() + .expect("restricted schema should produce query error") + .message + .contains("schema access is restricted")); + } + + #[test] + fn connection_form_uses_sqlite_specific_fields_by_default() { + let form = ConnectionFormState::new_create(); + + assert_eq!(form.kind, DatabaseKind::Sqlite); + assert_eq!( + form.visible_fields(), + vec![ + ConnectionFormField::ProfileName, + ConnectionFormField::DriverKind, + ConnectionFormField::SqlitePath, + ] + ); + } + + #[test] + fn connection_form_builds_postgres_profile_with_session_secret() { + let mut form = ConnectionFormState::new_create(); + form.name = String::from("reporting-postgres-copy"); + form.kind = DatabaseKind::Postgres; + form.host = String::from("127.0.0.1"); + form.port = String::from("55432"); + form.database = String::from("dbtool_demo"); + form.username = String::from("dbtool"); + form.password_env_var = String::from("DBTOOL_PASSWORD"); + form.session_password = String::from("session-secret"); + + let profile = form.build_profile().expect("profile should be valid"); + + assert_eq!(profile.name, "reporting-postgres-copy"); + assert_eq!(profile.target.kind, DatabaseKind::Postgres); + assert_eq!(profile.target.password.as_deref(), Some("session-secret")); + assert_eq!(profile.password_env_var.as_deref(), Some("DBTOOL_PASSWORD")); + } + + #[test] + fn suggest_profile_name_avoids_existing_saved_names() { + let app = App::default(); + + let suggested = app.suggest_profile_name("sqlite-local-copy"); + + assert_ne!(suggested, "sqlite-local"); + assert_ne!(suggested, "reporting-postgres"); + assert_ne!(suggested, "orders-mysql"); + } } diff --git a/backlog/2026-03-27-closeout-issue-package.md b/backlog/2026-03-27-closeout-issue-package.md new file mode 100644 index 0000000..61de186 --- /dev/null +++ b/backlog/2026-03-27-closeout-issue-package.md @@ -0,0 +1,168 @@ +# 2026-03-27 CTO Closeout Issue Package + +日期:2026-03-27 +作者:CTO + +## 目的 + +把当前已完成的大块实现,转换成下一批可直接分派、可验收、可关闭的 issue,避免团队继续在“功能已差不多”但“证据与签收未收口”的状态里空转。 + +## 当前判断 + +- `dbtool-cli-v1` 的主要风险已经从“功能未实现”切换到“发布证据不完整”。 +- `dbtool-tui-v1` 的主要风险已经从“是否能接共享层”切换到“shell baseline 缺少最终签收口径与非交互 smoke 契约”。 +- 当前不建议再创建大而模糊的“继续完善”任务;每个 issue 都应只解决一个可关闭问题。 + +## Proposed Issues + +### 1. QA:CLI 剩余负路径证据补齐 + +- **建议 owner**:QA Engineer +- **目标**:把 CLI 当前未闭合的失败路径从“文档里写了待验证”推进到“有命令、有环境、有输出摘要的证据” +- **范围** + - PostgreSQL / MySQL 错误凭据 + - PostgreSQL / MySQL 网络不可达 + - 空 schema / 空数据库反馈 + - 空结果集导出行为 + - 权限不足时的错误可读性 +- **不在范围** + - 新功能实现 + - 新数据库接入 + - TUI 验收 +- **输入文档** + - `ACCEPTANCE_CHECKLIST.md` + - `PRE_RELEASE_CHECKLIST.md` + - `TEST_STRATEGY.md` + - `SMOKE_RUNBOOK.md` +- **交付物** + - 更新后的验收清单 + - 每条负路径的执行命令与输出摘要 + - 未能执行项的 blocker 说明 +- **验收标准** + - 所有当前未勾选但应在 CLI v1 范围内的负路径,都有“已验证”或“明确 blocker” + - 失败路径输出不泄露 secrets + - 文档状态与证据状态一致 +- **依赖** + - 具备 Docker 的宿主机或等价 QA 环境 + +### 2. Release:跨平台 artifact 证据补齐 + +- **建议 owner**:Backend / Release Engineer +- **目标**:补齐 Linux / macOS / Windows release-smoke 的真实运行证据 +- **范围** + - GitHub Actions 或等价 runner 上的真实 workflow run + - artifact 名称、checksum、`--help`、`--version` + - 失败时的构建 / 打包 / smoke 分类 +- **不在范围** + - 新的打包格式 + - 发布渠道设计变更 +- **输入文档** + - `.github/workflows/release-smoke.yml` + - `RELEASE_RUNBOOK.md` + - `PRE_RELEASE_CHECKLIST.md` +- **交付物** + - 三平台 artifact smoke 证据 + - 若失败则给出按平台分类的问题单 + - 更新后的发布前清单 +- **验收标准** + - 三平台至少有一次可审计的真实运行记录 + - artifact 命名、checksum sidecar、帮助与版本输出都与 runbook 一致 + - 失败平台不会被模糊写成“待看” +- **依赖** + - GitHub-hosted runners 或等价 CI + +### 3. CTO:CLI 文档与 checklist 一致性收口 + +- **建议 owner**:CTO +- **目标**:把 README、runbook、测试策略、验收清单之间的当前口径彻底对齐 +- **范围** + - `README.md` + - `SMOKE_RUNBOOK.md` + - `RELEASE_RUNBOOK.md` + - `ACCEPTANCE_CHECKLIST.md` + - `PRE_RELEASE_CHECKLIST.md` + - `TEST_STRATEGY.md` +- **不在范围** + - 改功能语义 + - 改产品边界 +- **交付物** + - 一组状态一致、无明显互相冲突的文档 + - 当前已验证项 / 未验证项 / blocker 的统一口径 +- **验收标准** + - README 中的运行说明与实际可执行入口一致 + - 各文档不再同时出现“已完成”和“未开始”的冲突表述 + - 当前 runner 限制被明确写出,不伪装成本地已验证 +- **依赖** + - issue 1 和 issue 2 的最新证据 + +### 4. Frontend / TUI:非交互 smoke 与启动契约定义 + +- **建议 owner**:Senior Frontend Engineer +- **目标**:解决 `dbtool-tui` 在非 TTY 场景下直接报终端设备错误、无法形成稳定 QA / CI smoke 入口的问题 +- **范围** + - `dbtool-tui` 的启动前置检查与错误信息 + - `--help` 或等价非交互 smoke 行为 + - 文档中的启动契约 +- **不在范围** + - live database integration + - 新工作台能力 + - GUI 化改造 +- **输入文档** + - `apps/tui/README.md` + - `TUI_ACCEPTANCE_CHECKLIST.md` + - `TUI_TEST_STRATEGY.md` +- **交付物** + - 明确的非交互行为定义 + - 相关实现或文档修正 + - QA 可重复执行的 smoke 步骤 +- **验收标准** + - 非 TTY 启动不再只返回底层设备错误 + - QA / CI 能明确判断“环境不满足”还是“程序异常” + - shell baseline 的交互式能力不被破坏 +- **依赖** + - 当前 `apps/tui` shell baseline + +### 5. QA:TUI shell 签收收口 + +- **建议 owner**:QA Engineer +- **目标**:把当前 TUI shell baseline 从“已有实现”推进到“有明确签收状态” +- **范围** + - 六区布局 + - 焦点切换 + - 顶部视图切换 + - Ready / Loading / Error + - resize 降级 + - 非交互 smoke 契约联动复核 +- **不在范围** + - live 数据库连接 + - query/export 真实后端执行 +- **输入文档** + - `apps/tui/README.md` + - `TUI_ACCEPTANCE_CHECKLIST.md` + - `TUI_REGRESSION_CHECKLIST.md` +- **交付物** + - 更新后的 TUI 验收与回归文档 + - “已签收 / 未签收 / blocker” 的明确结论 +- **验收标准** + - `CMP-27` 所代表的 shell baseline 有最终签收判断 + - shell 验收与未来 live integration 验收被明确拆开 + +## Sequencing + +1. QA:CLI 剩余负路径证据补齐 +2. Release:跨平台 artifact 证据补齐 +3. CTO:CLI 文档与 checklist 一致性收口 +4. Frontend:TUI 非交互 smoke 与启动契约定义 +5. QA:TUI shell 签收收口 + +## Team Boundaries + +- **Backend / Release**:守住 CLI 主功能与产物证据,不扩 scope +- **Frontend**:只处理 TUI shell 契约与交互层,不引入 driver,不解析 CLI 文本 +- **QA**:把“写过文档”推进到“有证据能关闭” +- **CTO**:排序、收口、去歧义,不亲自替团队长期实现 + +## Hiring Judgment + +- 当前不建议启动招聘。 +- 若后续真实阻塞持续集中在 CI / release 基础设施而非产品实现,再考虑平台型支持,而不是盲目扩前后端人头。 diff --git a/backlog/2026-03-27-dbtool-tui-v1-phase-2-issues.md b/backlog/2026-03-27-dbtool-tui-v1-phase-2-issues.md new file mode 100644 index 0000000..6708612 --- /dev/null +++ b/backlog/2026-03-27-dbtool-tui-v1-phase-2-issues.md @@ -0,0 +1,142 @@ +# dbtool-tui-v1 第二阶段 owner-backed issues + +日期:2026-03-27 +作者:CTO +对应 issue:`CMP-33` + +## 当前判断 + +- 第一阶段 shell 已完成;第二阶段的目标是接入真实数据与真实执行状态,而不是继续堆占位界面。 +- 第二阶段必须保持 `db-app` 共享边界,不允许 TUI 走 CLI 文本解析旁路。 +- 当前不新增招聘;先用 owner-backed issue 验证真实吞吐与依赖。 + +## Issue 1:后端冻结 TUI live integration 契约 + +- **建议 owner**:Senior Backend Engineer +- **目标**:为 TUI 提供可直接消费的真实连接、inspect、query、export 集成边界 +- **范围** + - 明确 TUI 侧需要的 `db-app` 请求 / 响应 / 错误对象 + - 明确适合 worker / channel 回传的执行状态 + - 补共享回归,防止 CLI / TUI 语义漂移 +- **非目标** + - TUI 界面编码 + - 新数据库能力 +- **输入** + - `TUI_BACKEND_CONTRACT.md` + - `crates/db-app` + - `plans/2026-03-27-dbtool-tui-v1-phase-2-scope.md` +- **交付物** + - 更新后的共享契约 + - 对应回归测试 + - 明确的 frontend 接入说明 +- **验收标准** + - frontend 可以不依赖 CLI 文本直接接入真实数据 + - query / inspect / export 的状态和错误口径可稳定复用 + +## Issue 2:前端接入真实连接与 schema browser + +- **建议 owner**:Senior Frontend Engineer +- **目标**:让 `Connections` 与 `Schema Browser` 从占位状态进入真实 shared app 数据流 +- **范围** + - 激活连接 + - 展示连接成功 / 失败 / loading + - 拉取 schema / table / column + - 将活动连接上下文同步到 inspector / status 区 +- **非目标** + - query 执行 + - result table 真实渲染 +- **输入** + - `apps/tui` + - `apps/tui/README.md` + - `TUI_BACKEND_CONTRACT.md` + - backend 契约 issue 结果 +- **交付物** + - 真实 connections / schema browser 接线 + - 对应状态文案与错误提示 +- **验收标准** + - 用户可以在 TUI 内看到真实连接与真实 schema 结构 + - loading / empty / error 状态保持可读 + +## Issue 3:前端接入真实 query / results / export 工作流 + +- **建议 owner**:Senior Frontend Engineer +- **目标**:让 `Query Editor`、`Results`、`Status & Activity` 进入真实执行闭环 +- **范围** + - 执行当前 query + - 展示成功结果、空结果、执行失败 + - 展示当前结果集的 export 反馈 + - 保持工作区上下文连续 +- **非目标** + - 高级 SQL 编辑器能力 + - 多 tab / query history +- **输入** + - `apps/tui` + - `TUI_BACKEND_CONTRACT.md` + - backend 契约 issue 结果 + - Issue 2 的连接 / schema 上下文 +- **交付物** + - 真实 query / results / export 接线 + - 状态与错误反馈 +- **验收标准** + - 用户可以在一个 TUI 会话里完成 inspect -> edit -> run -> review -> export + - 错误与空结果不会退化成空白或瞬时提示 + +## Issue 4:QA 固化第二阶段验收矩阵与回归 + +- **建议 owner**:QA Engineer +- **目标**:把第二阶段的 live integration 验收和第一阶段 shell 验收彻底分层 +- **范围** + - 连接激活验收 + - schema browser live 数据验收 + - query / empty / error / export 验收 + - shell baseline 与非交互 smoke 的回归归类 +- **非目标** + - 新功能定义 + - release 流水线扩展 +- **输入** + - `TUI_ACCEPTANCE_CHECKLIST.md` + - `TUI_REGRESSION_CHECKLIST.md` + - `TUI_TEST_STRATEGY.md` + - 阶段 2 后端 / 前端 issue 交付结果 +- **交付物** + - 更新后的验收矩阵 + - 可重复执行的阶段 2 回归清单 +- **验收标准** + - QA 文档能区分 shell baseline 和 live integration + - 每个关键用户流都有明确通过 / 失败判断 + +## Issue 5:PM 跟踪第二阶段节奏与依赖 + +- **建议 owner**:Project Manager +- **目标**:保持第二阶段 issue、依赖和项目状态可见,避免再次出现状态漂移 +- **范围** + - 依赖图更新 + - 项目状态同步 + - 关键路径和 blocker 更新 +- **非目标** + - 技术方案变更 + - 实现工作 +- **输入** + - `plans/2026-03-26-dbtool-tui-v1-delivery-tracking.md` + - 新创建的阶段 2 owner-backed issues +- **交付物** + - 更新后的 delivery tracking 文档 + - 当前关键路径、blocker、owner 分布 +- **验收标准** + - 项目状态与 issue 面一致 + - 第二阶段主路径对 CTO / CEO 可见 + +## Sequencing + +1. 后端冻结 TUI live integration 契约 +2. 前端接入真实连接与 schema browser +3. 前端接入真实 query / results / export +4. QA 固化第二阶段验收矩阵与回归 +5. PM 跟踪第二阶段节奏与依赖 + +## Gating + +- Issue 1 未完成前,Issue 2 / 3 不得绕过 shared app 直接写业务旁路 +- Issue 2 未完成前,Issue 3 不应假定活动连接上下文已经稳定 +- Issue 4 不能替代实现 issue,只负责把结果转成可审计验收路径 +- Issue 5 不应把“项目 planned”继续保留到第二阶段已经开工之后 diff --git a/backlog/2026-03-27-dbtool-usable-v1-first-wave-issues.md b/backlog/2026-03-27-dbtool-usable-v1-first-wave-issues.md new file mode 100644 index 0000000..9df1412 --- /dev/null +++ b/backlog/2026-03-27-dbtool-usable-v1-first-wave-issues.md @@ -0,0 +1,127 @@ +# 2026-03-27 `dbtool-usable-v1` 第一轮 issue package + +日期:2026-03-27 +作者:CTO +对应父 issue:`CMP-40` + +## 设计原则 + +- 先有 parent issue,再有 narrow child issue。 +- child issue 必须能被单个 owner 清晰完成。 +- 不复制 `dbtool-cli-v1` 和 `dbtool-tui-v1` 既有执行链。 +- 若只是发现风险但还没有具体缺陷,不创建“继续完善”式大包。 + +## 第一轮 child issues + +### 1. PM child issue + +- **标题**:同步 `dbtool-usable-v1` 的里程碑、依赖图与项目状态口径 +- **Parent**:`CMP-41` +- **Owner**:Project Manager +- **Why now**:usable-v1 已经有独立 parent issue,但还没有自己的共享 tracking 文档和固定同步口径。 +- **Scope** + - 建 usable-v1 delivery tracking 文档 + - 标出当前关键路径、依赖关系、真实 blocker + - 明确与 `dbtool-cli-v1` / `dbtool-tui-v1` 的项目边界 +- **Acceptance** + - 有共享 tracking 文档 + - 有固定状态同步规则 + - 没有无 owner 的 open 工作包 + +### 2. Backend child issue + +- **标题**:冻结 `dbtool-usable-v1` 的 shared app / CLI 契约与错误边界 +- **Parent**:`CMP-42` +- **Owner**:Senior Backend Engineer +- **Why now**:usable-v1 的 CLI 与 TUI 结论都依赖 `db-app` 成为唯一可信共享契约;当前需要把这一层正式收口成可被 QA 和前端引用的稳定边界。 +- **Scope** + - 盘点 `db-app` 对 `connect`、`inspect`、`query`、`export` 的结构化状态 + - 识别 CLI 侧是否还有 shared contract 漏洞或错误边界不一致 + - 输出 usable-v1 视角的 contract gap / blocker list +- **Non-goals** + - 不重做 `CMP-34` 的 live wiring 方案 + - 不扩新的数据库能力 + - 不把 defect 修复和契约盘点混成大包 +- **Acceptance** + - 有 contract snapshot 或等价共享说明 + - 有 gap / blocker 清单 + - 新发现的后端缺陷被拆成单独 issue,而不是留在描述里 + +### 3. Frontend child issue A + +- **标题**:定义并收口 `dbtool-tui` 的 TTY smoke 契约与启动限制 +- **Parent**:`CMP-43` +- **Owner**:Senior Frontend Engineer +- **Why now**:当前 `dbtool-tui --help` 在非 TTY 场景直接失败,TUI 还没有 usable-v1 可接受的 smoke / 启动口径。 +- **Scope** + - 明确 TTY-required 启动路径 + - 明确非 TTY 场景的预期行为或错误提示 + - 补 TUI smoke runbook / README 对应说明 + - 为 QA / CI 提供最小可重复入口 +- **Acceptance** + - 有明确 smoke 契约 + - QA 能按文档复现最小启动验证 + - 当前限制被明确记录,不再靠口头说明 + +### 4. Frontend child issue B + +- **标题**:完成 `dbtool-tui` 的 usable-v1 状态恢复与键盘一致性签收修正 +- **Parent**:`CMP-43` +- **Owner**:Senior Frontend Engineer +- **Why now**:当前 TUI 已有真实工作台,但 usable-v1 还缺“失败后怎么恢复、空状态怎么看、键盘路径是否一致”的签收收口。 +- **Dependencies** + - 依赖 `CMP-35`、`CMP-36` 的 live integration 结果 +- **Scope** + - 收口 loading / empty / error / retry / export feedback 的连续性 + - 收口关键键位在不同视图下的一致性与可理解性 + - 仅做 usable-v1 签收所需的交互修正 +- **Non-goals** + - 不进入新功能扩张 + - 不新增 GUI 化需求 +- **Acceptance** + - QA 可按 checklist 复核关键恢复路径 + - 关键键盘路径无自相矛盾行为 + - 当前不支持的行为被明确标为限制 + +### 5. QA child issue + +- **标题**:建立 `dbtool-usable-v1` 的统一验收矩阵、证据台账与发布门槛 +- **Parent**:`CMP-44` +- **Owner**:QA Engineer +- **Why now**:当前 CLI、TUI、runtime、artifact、TTY smoke 分散在多份文档中,usable-v1 还缺一个统一的 evidence ledger。 +- **Scope** + - 建 usable-v1 acceptance matrix + - 建已验证 / 未验证 / blocked / out-of-scope 台账 + - 建 release gate 结论模板 + - 区分环境 blocker 与产品 blocker +- **Acceptance** + - 有统一矩阵 + - 有 blocker / risk 清单 + - 可对 usable-v1 给出 evidence-based 通过/不通过结论 + +## 当前不立即创建的 issue + +- **CLI 新功能扩张 issue**:当前不需要;CLI 主功能已齐,重点是证据与一致性。 +- **TUI 重做架构 issue**:当前不需要;现有问题更偏启动契约和签收收口。 +- **招聘相关 issue**:当前不需要;还没有证据证明吞吐瓶颈来自 headcount。 + +## 推荐执行顺序 + +```text +PM tracking child + ↓ +Backend contract child + ↓ +Frontend TTY smoke child + ↓ +Frontend usability signoff child + ↓ +QA unified acceptance child +``` + +并行说明: + +- PM tracking 可立即启动 +- Backend contract child 可立即启动 +- QA matrix 可先搭框,但最终结论依赖前置 child issue +- Frontend usability signoff child 受 `CMP-35` / `CMP-36` gating diff --git a/backlog/dbtool-tui-v1-first-wave-issues.md b/backlog/dbtool-tui-v1-first-wave-issues.md new file mode 100644 index 0000000..ae647c0 --- /dev/null +++ b/backlog/dbtool-tui-v1-first-wave-issues.md @@ -0,0 +1,56 @@ +# dbtool-tui-v1 第一轮工程 backlog + +日期:2026-03-27 +作者:CTO + +## 当前判断 + +- `apps/tui` shell 已有基线;当前共享工作区里,`db-app` 也已经成为 CLI 的实际执行路径。 +- 第一轮 TUI 工程已从“是否能接共享层”切换到“评审签收与验证口径收口”。 +- 当前不新增招聘;先正式收口 `db-app` 契约,再推进 TUI 接口复用。 + +## 当前 issue 状态 + +- 已完成:`CMP-28`、`CMP-29`、`CMP-30`、`CMP-31`、`CMP-32` +- 评审中:`CMP-27` +- 跟踪收口中:`CMP-26` + +## 当前主路径 + +1. `[CMP-27](/CMP/issues/CMP-27)` shell baseline 评审签收 +2. `[CMP-26](/CMP/issues/CMP-26)` 项目状态与交付口径收口 +3. 如需进入下一阶段,再新建 owner-backed issue,而不是继续口头延伸当前首波 + +## 下一批建议 issue(仅在明确扩 scope 后启动) + +- 前端:TUI 非交互 smoke / 启动契约 + - 范围:明确 `dbtool-tui` 在非 TTY、CI、帮助信息场景下的预期行为 + - 验收:不会把当前 shell 程序误判为“无法运行”,且 QA 有统一 smoke 入口 + +- 后端:live db-app 集成节奏定义 + - 范围:何时把真实连接 / inspect / query / export 接进 TUI + - 验收:不绕过 `db-app`,不让 TUI 解析 CLI 文本 + +- QA:TUI shell 与 live integration 分层回归 + - 范围:把 shell baseline 回归与未来 live database 回归分开 + - 验收:首版 shell 不被后续 live 能力污染验收口径 + +## owner 边界 + +- 后端:`CMP-31` + - owner:Senior Backend Engineer + - 结果:固定 `db-app` 契约、补回归、确认 CLI 共享路径可被 TUI 稳定复用 + +- 前端:`CMP-28` / `CMP-29` / `CMP-30` + - owner:Senior Frontend Engineer + - 结果:TUI 只消费结构化 response,不解析 CLI 文本,不引入 driver 依赖 + +- QA:`CMP-32` + - owner:QA Engineer + - 结果:shell、连接、schema、query、结果、错误、resize 都有回归路径 + +## gated 条件 + +- 当前仍不得把 TUI 误扩成桌面 GUI 或多连接工作台。 +- 当前不得绕过 `db-app` 共享契约去解析 CLI 文本输出。 +- 当前 CTO heartbeat 缺少 `cargo` 与 `docker`;本地只能做现有二进制和文档核对,不能单机关闭更高阶验证 gate。 diff --git a/backlog/first-wave-issues.md b/backlog/first-wave-issues.md index f459ef2..bb2fa63 100644 --- a/backlog/first-wave-issues.md +++ b/backlog/first-wave-issues.md @@ -1,8 +1,16 @@ # dbtool-cli-v1 第一轮工程 backlog -日期:2026-03-25 +日期:2026-03-27 +作者:CTO -## 已分配 +## 当前结论 + +- 第一轮 CLI 功能性 issue 已基本收口:workspace、三库驱动、query / export、demo 资产、runbook、release-smoke workflow 都已落地。 +- `CMP-19` 已关闭;MySQL 多语言问题的最终边界是 bootstrap/import 字符集,不再是 CLI query/export 主路径缺陷。 +- 当前主风险已从“功能没写完”切换为“证据与发布链路是否闭环”。 +- 前端仍保持 gated;当前不为 CLI v1 新增 GUI 实现工作。 + +## 已完成的第一轮 owner-backed issue - 后端:`CMP-4` Rust workspace 初始化 - 后端:`CMP-5` PostgreSQL 驱动 @@ -10,24 +18,37 @@ - 后端:`CMP-7` SQLite 驱动 - 后端:`CMP-8` CSV / JSON 导出 - 后端:`CMP-12` CLI 打包、发布与跨平台 smoke 流水线 +- 后端:`CMP-19` MySQL 中文输出问题修复与回归闭环 - 前端:`CMP-9` GUI 信息架构和设计基线 - QA:`CMP-10` 跨数据库验收矩阵 - QA:`CMP-13` demo 数据库、样例脚本和 smoke runbook - PM:`CMP-11` 里程碑、依赖图和交付跟踪 -## 执行顺序 +## 当前剩余工作流 -1. `CMP-4` -2. `CMP-5` -3. `CMP-10` 与 `CMP-13` 并行启动 -4. `CMP-6` -5. `CMP-7` -6. `CMP-8` -7. `CMP-12` -8. `CMP-11` 持续跟踪里程碑和关键路径 +1. QA 回填剩余负路径证据 +2. Release owner 补齐跨平台 artifact 运行证据 +3. CTO 复核 README / checklist / runbook 的最终一致性 +4. CEO / PM 确认 CLI v1 是否以“证据收口完成”口径进入发布准备 + +## 建议新增的收口 issue + +- QA:CLI 负路径证据补齐 + - 范围:错误凭据、网络不可达、空 schema / 空结果导出、权限不足 + - 验收:`ACCEPTANCE_CHECKLIST.md` 与 `PRE_RELEASE_CHECKLIST.md` 中对应未勾选项有证据或有明确 blocker + +- 后端 / Release:跨平台 artifact 证据补齐 + - 范围:GitHub Actions 上的 Linux / macOS / Windows release-smoke 实跑记录 + - 验收:产物、checksum、`--help`、`--version` 证据可审计 + +- CTO:共享文档一致性收口 + - 范围:`README.md`、`SMOKE_RUNBOOK.md`、`RELEASE_RUNBOOK.md`、验收/发布清单 + - 验收:命令、边界、当前 blocker 与实际状态一致 ## 当前判断 -- 真正的起跑线不是“写功能”,而是先把仓库和最小 CLI 基线建起来。 -- 前端当前应保持 gated,只输出未来 GUI 基线,不抢占 CLI 主路径资源。 -- QA 不应等功能全部完成后再介入,应从 fixtures、矩阵和 smoke runbook 开始提前进入。 +- CLI v1 现在不是招聘问题,而是发布证据治理问题。 +- 当前 runner 缺少 `cargo` 与 `docker`,所以 CTO 本地只能复核现有二进制与 SQLite 链路,不能独立关闭 Rust 构建或 Docker runtime gate。 +- 发布口径应明确区分: + - 已落地:CLI 主功能、SQLite 本地闭环、Linux artifact smoke + - 需外部证据:PostgreSQL / MySQL Docker runtime、macOS / Windows release artifact diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs index b52eb1d..c3a9f5e 100644 --- a/crates/db-app/src/lib.rs +++ b/crates/db-app/src/lib.rs @@ -1,7 +1,11 @@ -use db_config::ConnectionProfile; +use db_config::{ + ConfigError, ConnectionProfile, ConnectionProfileCatalog, ConnectionProfileStore, + SessionSecretStore, +}; use db_core::{ ColumnSummary, CoreError, ExportFormat, ExportRequest, InspectRequest, InspectResult, - QueryRequest, QueryResult, QuerySource, SchemaSummary, TableSummary, + QueryRequest, QueryResult, QuerySource, SchemaAvailability as CoreSchemaAvailability, + SchemaSummary, TableSummary, }; use db_drivers::{DriverError, DriverRegistry}; use serde::Serialize; @@ -14,6 +18,10 @@ pub enum AppOperation { Inspect, Query, Export, + LoadProfiles, + SaveProfile, + DeleteProfile, + ActivateProfile, } impl AppOperation { @@ -23,6 +31,10 @@ impl AppOperation { Self::Inspect => "inspect", Self::Query => "query", Self::Export => "export", + Self::LoadProfiles => "load_profiles", + Self::SaveProfile => "save_profile", + Self::DeleteProfile => "delete_profile", + Self::ActivateProfile => "activate_profile", } } @@ -32,6 +44,10 @@ impl AppOperation { "inspect" => Self::Inspect, "query" => Self::Query, "export" => Self::Export, + "load_profiles" => Self::LoadProfiles, + "save_profile" => Self::SaveProfile, + "delete_profile" => Self::DeleteProfile, + "activate_profile" => Self::ActivateProfile, other => panic!("unsupported app operation: {other}"), } } @@ -115,6 +131,41 @@ impl ConnectResponse { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SecretSource { + None, + Session, + EnvVar, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct StoredProfileSummary { + pub target: ConnectionSummary, + pub is_active: bool, + pub secret_source: SecretSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ProfileCatalogResponse { + pub active_profile_name: Option, + pub profiles: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionSecretInput { + Preserve, + Set(String), + Clear, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SaveProfileRequest { + pub profile: ConnectionProfile, + pub set_active: bool, + pub session_secret: SessionSecretInput, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct InspectScope { pub schema: Option, @@ -164,14 +215,37 @@ impl InspectResponse { } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SchemaAvailability { + Ready, + Restricted, +} + +impl From for SchemaAvailability { + fn from(value: CoreSchemaAvailability) -> Self { + match value { + CoreSchemaAvailability::Ready => Self::Ready, + CoreSchemaAvailability::Restricted => Self::Restricted, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SchemaItem { pub name: String, + pub availability: SchemaAvailability, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, } impl From for SchemaItem { fn from(value: SchemaSummary) -> Self { - Self { name: value.name } + Self { + name: value.name, + availability: SchemaAvailability::from(value.availability), + note: value.note, + } } } @@ -349,6 +423,7 @@ pub enum AppErrorKind { Inspect, Query, Export, + Config, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -397,6 +472,120 @@ pub fn connect(profile: &ConnectionProfile) -> Result }) } +pub fn load_profile_catalog( + store: &ConnectionProfileStore, + secrets: &SessionSecretStore, +) -> Result { + let catalog = load_catalog(store, "load_profiles")?; + Ok(profile_catalog_response(&catalog, secrets)) +} + +pub fn save_profile( + store: &ConnectionProfileStore, + secrets: &mut SessionSecretStore, + request: SaveProfileRequest, +) -> Result { + validate_profile("save_profile", &request.profile)?; + + let inline_secret = request.profile.target.password.clone(); + let mut catalog = load_catalog(store, "save_profile")?; + catalog + .upsert_profile(request.profile.clone()) + .map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?; + + if request.set_active { + catalog + .set_active_profile(Some(&request.profile.name)) + .map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?; + } + + apply_session_secret_change( + secrets, + &request.profile, + request.session_secret, + inline_secret, + ) + .map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?; + + save_catalog(store, "save_profile", Some(&request.profile), &catalog)?; + Ok(profile_catalog_response(&catalog, secrets)) +} + +pub fn delete_profile( + store: &ConnectionProfileStore, + secrets: &mut SessionSecretStore, + profile_name: &str, +) -> Result { + let mut catalog = load_catalog(store, "delete_profile")?; + if catalog.remove_profile(profile_name).is_none() { + return Err(map_config_error( + "delete_profile", + None, + ConfigError::ProfileNotFound(profile_name.to_string()), + )); + } + + secrets.remove(profile_name); + save_catalog(store, "delete_profile", None, &catalog)?; + + Ok(profile_catalog_response(&catalog, secrets)) +} + +pub fn activate_profile( + store: &ConnectionProfileStore, + secrets: &SessionSecretStore, + profile_name: &str, +) -> Result { + let mut catalog = load_catalog(store, "activate_profile")?; + catalog + .set_active_profile(Some(profile_name)) + .map_err(|error| map_config_error("activate_profile", None, error))?; + save_catalog( + store, + "activate_profile", + catalog.profile(profile_name), + &catalog, + )?; + + Ok(profile_catalog_response(&catalog, secrets)) +} + +pub fn resolve_profile( + store: &ConnectionProfileStore, + secrets: &SessionSecretStore, + profile_name: &str, +) -> Result { + let catalog = load_catalog(store, "load_profiles")?; + let profile = catalog.profile(profile_name).ok_or_else(|| { + map_config_error( + "load_profiles", + None, + ConfigError::ProfileNotFound(profile_name.to_string()), + ) + })?; + + Ok(secrets.materialize_profile(profile)) +} + +pub fn resolve_active_profile( + store: &ConnectionProfileStore, + secrets: &SessionSecretStore, +) -> Result, AppError> { + let catalog = load_catalog(store, "load_profiles")?; + Ok(catalog + .active_profile() + .map(|profile| secrets.materialize_profile(profile))) +} + +pub fn connect_saved_profile( + store: &ConnectionProfileStore, + secrets: &SessionSecretStore, + profile_name: &str, +) -> Result { + let profile = resolve_profile(store, secrets, profile_name)?; + connect(&profile) +} + pub fn inspect( profile: &ConnectionProfile, request: &InspectRequest, @@ -570,6 +759,93 @@ fn map_driver_error( } } +fn load_catalog( + store: &ConnectionProfileStore, + operation: &'static str, +) -> Result { + store + .load() + .map_err(|error| map_config_error(operation, None, error)) +} + +fn save_catalog( + store: &ConnectionProfileStore, + operation: &'static str, + profile: Option<&ConnectionProfile>, + catalog: &ConnectionProfileCatalog, +) -> Result<(), AppError> { + store + .save(catalog) + .map_err(|error| map_config_error(operation, profile, error)) +} + +fn map_config_error( + operation: &'static str, + profile: Option<&ConnectionProfile>, + error: ConfigError, +) -> AppError { + AppError { + operation, + kind: AppErrorKind::Config, + message: error.to_string(), + target: profile.map(ConnectionSummary::from), + } +} + +fn profile_catalog_response( + catalog: &ConnectionProfileCatalog, + secrets: &SessionSecretStore, +) -> ProfileCatalogResponse { + ProfileCatalogResponse { + active_profile_name: catalog.active_profile.clone(), + profiles: catalog + .profiles + .iter() + .map(|profile| StoredProfileSummary { + target: ConnectionSummary::from(profile), + is_active: catalog.active_profile.as_deref() == Some(profile.name.as_str()), + secret_source: profile_secret_source(profile, secrets), + }) + .collect(), + } +} + +fn profile_secret_source( + profile: &ConnectionProfile, + secrets: &SessionSecretStore, +) -> SecretSource { + if secrets.contains(&profile.name) { + SecretSource::Session + } else if profile.password_env_var.is_some() { + SecretSource::EnvVar + } else { + SecretSource::None + } +} + +fn apply_session_secret_change( + secrets: &mut SessionSecretStore, + profile: &ConnectionProfile, + session_secret: SessionSecretInput, + inline_secret: Option, +) -> Result<(), ConfigError> { + match session_secret { + SessionSecretInput::Preserve => { + if let Some(secret) = inline_secret { + secrets.set(profile.name.clone(), secret)?; + } + } + SessionSecretInput::Set(secret) => { + secrets.set(profile.name.clone(), secret)?; + } + SessionSecretInput::Clear => { + secrets.remove(&profile.name); + } + } + + Ok(()) +} + fn export_format_name(format: ExportFormat) -> &'static str { match format { ExportFormat::Csv => "csv", @@ -701,6 +977,21 @@ mod tests { ); } + #[test] + fn schema_item_preserves_restricted_availability_and_note() { + let item = SchemaItem::from(SchemaSummary::restricted( + "restricted_probe", + "schema access is restricted: restricted_probe", + )); + + assert_eq!(item.name, "restricted_probe"); + assert_eq!(item.availability, SchemaAvailability::Restricted); + assert_eq!( + item.note.as_deref(), + Some("schema access is restricted: restricted_probe") + ); + } + #[test] fn inspect_response_uses_empty_state_for_empty_payload() { let response = InspectResponse { @@ -796,6 +1087,110 @@ mod tests { assert!(json.get("data").is_none()); } + #[test] + fn save_profile_persists_redacted_catalog_and_keeps_session_secret_only_in_memory() { + let path = temp_store_path("dbtool-app-profile-save"); + let store = ConnectionProfileStore::new(&path); + let mut secrets = SessionSecretStore::default(); + let profile = ConnectionProfile::new( + "reporting-postgres", + ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 55432, + }, + Some(String::from("dbtool_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"), + Some(String::from("DBTOOL_PASSWORD")), + ) + .expect("profile should be valid") + .with_password(Some(String::from("super-secret"))); + + let response = save_profile( + &store, + &mut secrets, + SaveProfileRequest { + profile, + set_active: true, + session_secret: SessionSecretInput::Preserve, + }, + ) + .expect("profile should save"); + let raw = fs::read_to_string(&path).expect("store file should exist"); + let resolved = + resolve_active_profile(&store, &secrets).expect("active profile should resolve"); + + assert_eq!( + response.active_profile_name.as_deref(), + Some("reporting-postgres") + ); + assert_eq!(response.profiles.len(), 1); + assert_eq!(response.profiles[0].secret_source, SecretSource::Session); + assert!(!raw.contains("super-secret")); + assert!(!raw.contains("\"password\":")); + assert_eq!( + resolved + .expect("active profile should exist") + .target + .password + .as_deref(), + Some("super-secret") + ); + + fs::remove_file(path).expect("temporary store should be removed"); + } + + #[test] + fn connect_saved_profile_uses_persisted_sqlite_target() { + let db_path = temp_sqlite_db_path("dbtool-app-connect-saved"); + let connection = + SqliteConnection::open(&db_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 store_path = temp_store_path("dbtool-app-profile-connect"); + let store = ConnectionProfileStore::new(&store_path); + let mut catalog = ConnectionProfileCatalog::empty(); + catalog + .upsert_profile( + ConnectionProfile::new( + "sqlite-local", + ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { + path: db_path.clone(), + }, + None, + None, + ) + .expect("target should be valid"), + None, + ) + .expect("profile should be valid"), + ) + .expect("profile should be inserted"); + catalog + .set_active_profile(Some("sqlite-local")) + .expect("active profile should be set"); + store.save(&catalog).expect("catalog should save"); + + let response = + connect_saved_profile(&store, &SessionSecretStore::default(), "sqlite-local") + .expect("saved sqlite profile should connect"); + + assert_eq!(response.target.profile_name, "sqlite-local"); + assert_eq!(response.target.driver, "sqlite"); + assert_eq!(response.status, "connected"); + + fs::remove_file(store_path).expect("temporary store should be removed"); + fs::remove_file(db_path).expect("temporary sqlite database should be removed"); + } + fn connection_summary() -> ConnectionSummary { ConnectionSummary { profile_name: String::from("qa-demo"), @@ -812,4 +1207,12 @@ mod tests { .as_nanos(); env::temp_dir().join(format!("{prefix}-{unique}.sqlite")) } + + fn temp_store_path(prefix: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{unique}.json")) + } } diff --git a/crates/db-config/Cargo.toml b/crates/db-config/Cargo.toml index 4391365..1b8b6e6 100644 --- a/crates/db-config/Cargo.toml +++ b/crates/db-config/Cargo.toml @@ -7,7 +7,8 @@ publish = false [dependencies] db-core.workspace = true +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" [lints] workspace = true - diff --git a/crates/db-config/src/lib.rs b/crates/db-config/src/lib.rs index 2089ebf..8a42547 100644 --- a/crates/db-config/src/lib.rs +++ b/crates/db-config/src/lib.rs @@ -1,6 +1,13 @@ use db_core::{ConnectionTarget, CoreError}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use std::{env, error::Error, fmt, fs, io}; -#[derive(Clone, Debug, PartialEq, Eq)] +pub const PROFILE_STORE_VERSION: u32 = 1; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionProfile { pub name: String, pub target: ConnectionTarget, @@ -46,11 +53,350 @@ impl ConnectionProfile { self.target.password = password; self } + + pub fn without_password(mut self) -> Self { + self.target.password = None; + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnectionProfileCatalog { + #[serde(default = "profile_store_version")] + pub version: u32, + #[serde(default)] + pub active_profile: Option, + #[serde(default)] + pub profiles: Vec, +} + +impl Default for ConnectionProfileCatalog { + fn default() -> Self { + Self::empty() + } +} + +impl ConnectionProfileCatalog { + pub fn empty() -> Self { + Self { + version: PROFILE_STORE_VERSION, + active_profile: None, + profiles: Vec::new(), + } + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != PROFILE_STORE_VERSION { + return Err(ConfigError::UnsupportedVersion(self.version)); + } + + let mut seen_names = BTreeSet::new(); + for profile in &self.profiles { + profile.validate().map_err(ConfigError::Core)?; + + if profile.target.password.is_some() { + return Err(ConfigError::SecretPersistenceForbidden( + profile.name.clone(), + )); + } + + if !seen_names.insert(profile.name.clone()) { + return Err(ConfigError::DuplicateProfileName(profile.name.clone())); + } + } + + if let Some(active_profile) = &self.active_profile { + if self.profile(active_profile).is_none() { + return Err(ConfigError::MissingActiveProfile(active_profile.clone())); + } + } + + Ok(()) + } + + pub fn profile(&self, name: &str) -> Option<&ConnectionProfile> { + self.profiles.iter().find(|profile| profile.name == name) + } + + pub fn active_profile(&self) -> Option<&ConnectionProfile> { + self.active_profile + .as_deref() + .and_then(|name| self.profile(name)) + } + + pub fn upsert_profile(&mut self, profile: ConnectionProfile) -> Result<(), ConfigError> { + let sanitized = profile.without_password(); + sanitized.validate().map_err(ConfigError::Core)?; + + if let Some(existing) = self + .profiles + .iter_mut() + .find(|existing| existing.name == sanitized.name) + { + *existing = sanitized; + } else { + self.profiles.push(sanitized); + } + + self.validate() + } + + pub fn remove_profile(&mut self, name: &str) -> Option { + let index = self + .profiles + .iter() + .position(|profile| profile.name == name)?; + let removed = self.profiles.remove(index); + + if self.active_profile.as_deref() == Some(name) { + self.active_profile = None; + } + + Some(removed) + } + + pub fn set_active_profile(&mut self, name: Option<&str>) -> Result<(), ConfigError> { + match name { + Some(name) => { + if self.profile(name).is_none() { + return Err(ConfigError::ProfileNotFound(name.to_string())); + } + self.active_profile = Some(name.to_string()); + } + None => { + self.active_profile = None; + } + } + + self.validate() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SessionSecretStore { + secrets: BTreeMap, +} + +impl SessionSecretStore { + pub fn set( + &mut self, + profile_name: impl Into, + secret: impl Into, + ) -> Result<(), ConfigError> { + let profile_name = profile_name.into(); + let secret = secret.into(); + + if profile_name.trim().is_empty() { + return Err(ConfigError::InvalidSessionSecret( + "profile name is empty".into(), + )); + } + + if secret.is_empty() { + return Err(ConfigError::InvalidSessionSecret( + "session secret is empty".into(), + )); + } + + self.secrets.insert(profile_name, secret); + Ok(()) + } + + pub fn remove(&mut self, profile_name: &str) -> Option { + self.secrets.remove(profile_name) + } + + pub fn contains(&self, profile_name: &str) -> bool { + self.secrets.contains_key(profile_name) + } + + pub fn resolve_password(&self, profile: &ConnectionProfile) -> Option { + self.secrets.get(&profile.name).cloned().or_else(|| { + profile + .password_env_var + .as_ref() + .and_then(|env_name| env::var(env_name).ok()) + }) + } + + pub fn materialize_profile(&self, profile: &ConnectionProfile) -> ConnectionProfile { + profile + .clone() + .with_password(self.resolve_password(profile)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionProfileStore { + path: PathBuf, +} + +impl ConnectionProfileStore { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result { + if !self.path.exists() { + return Ok(ConnectionProfileCatalog::empty()); + } + + let body = fs::read_to_string(&self.path).map_err(|source| ConfigError::Io { + path: self.path.clone(), + operation: "read", + source, + })?; + let catalog = + serde_json::from_str::(&body).map_err(|source| { + ConfigError::Parse { + path: self.path.clone(), + source, + } + })?; + + catalog.validate()?; + Ok(catalog) + } + + pub fn save(&self, catalog: &ConnectionProfileCatalog) -> Result<(), ConfigError> { + catalog.validate()?; + + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).map_err(|source| ConfigError::Io { + path: parent.to_path_buf(), + operation: "create_dir_all", + source, + })?; + } + + let body = serde_json::to_string_pretty(catalog).map_err(ConfigError::Serialize)?; + let temp_path = self.temporary_path(); + + fs::write(&temp_path, body).map_err(|source| ConfigError::Io { + path: temp_path.clone(), + operation: "write", + source, + })?; + + if self.path.exists() { + fs::remove_file(&self.path).map_err(|source| ConfigError::Io { + path: self.path.clone(), + operation: "remove", + source, + })?; + } + + fs::rename(&temp_path, &self.path).map_err(|source| ConfigError::Io { + path: self.path.clone(), + operation: "rename", + source, + })?; + + Ok(()) + } + + fn temporary_path(&self) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + + self.path.with_extension(format!("tmp-{unique}")) + } +} + +fn profile_store_version() -> u32 { + PROFILE_STORE_VERSION +} + +#[derive(Debug)] +pub enum ConfigError { + Core(CoreError), + DuplicateProfileName(String), + MissingActiveProfile(String), + ProfileNotFound(String), + SecretPersistenceForbidden(String), + InvalidSessionSecret(String), + UnsupportedVersion(u32), + Io { + path: PathBuf, + operation: &'static str, + source: io::Error, + }, + Parse { + path: PathBuf, + source: serde_json::Error, + }, + Serialize(serde_json::Error), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Core(error) => error.fmt(formatter), + Self::DuplicateProfileName(name) => { + write!(formatter, "connection profile `{name}` is duplicated") + } + Self::MissingActiveProfile(name) => { + write!( + formatter, + "active profile `{name}` is missing from the catalog" + ) + } + Self::ProfileNotFound(name) => { + write!(formatter, "connection profile `{name}` was not found") + } + Self::SecretPersistenceForbidden(name) => write!( + formatter, + "connection profile `{name}` contains an in-memory secret and cannot be persisted" + ), + Self::InvalidSessionSecret(message) => formatter.write_str(message), + Self::UnsupportedVersion(version) => write!( + formatter, + "connection profile store version `{version}` is unsupported" + ), + Self::Io { + path, + operation, + source, + } => write!( + formatter, + "failed to {operation} connection profile store {}: {source}", + path.display() + ), + Self::Parse { path, source } => write!( + formatter, + "failed to parse connection profile store {}: {source}", + path.display() + ), + Self::Serialize(source) => write!( + formatter, + "failed to serialize connection profile store: {source}" + ), + } + } +} + +impl Error for ConfigError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Core(error) => Some(error), + Self::Io { source, .. } => Some(source), + Self::Parse { source, .. } => Some(source), + Self::Serialize(source) => Some(source), + _ => None, + } + } } #[cfg(test)] mod tests { - use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind}; + use db_core::{ConnectionTransport, DatabaseKind}; + use std::time::{SystemTime, UNIX_EPOCH}; use super::*; @@ -120,4 +466,108 @@ mod tests { "postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD" ); } + + #[test] + fn catalog_rejects_profiles_with_in_memory_passwords() { + let profile = sample_postgres_profile().with_password(Some(String::from("secret"))); + let catalog = ConnectionProfileCatalog { + version: PROFILE_STORE_VERSION, + active_profile: Some(String::from("local")), + profiles: vec![profile], + }; + + let error = catalog + .validate() + .expect_err("catalog should reject secrets"); + + assert!(matches!( + error, + ConfigError::SecretPersistenceForbidden(name) if name == "local" + )); + } + + #[test] + fn store_roundtrip_preserves_active_profile_without_password() { + let path = temp_store_path("db-config-store-roundtrip"); + let store = ConnectionProfileStore::new(&path); + let mut catalog = ConnectionProfileCatalog::empty(); + + catalog + .upsert_profile(sample_postgres_profile()) + .expect("profile should be inserted"); + catalog + .set_active_profile(Some("local")) + .expect("active profile should be valid"); + + store.save(&catalog).expect("catalog should save"); + let raw = fs::read_to_string(&path).expect("store file should exist"); + let restored = store.load().expect("catalog should load"); + + assert!(raw.contains("\"active_profile\": \"local\"")); + assert!(!raw.contains("\"password\":")); + assert_eq!(restored.active_profile.as_deref(), Some("local")); + assert_eq!(restored.profiles.len(), 1); + assert_eq!(restored.profiles[0].target.password, None); + + fs::remove_file(path).expect("temporary store should be removed"); + } + + #[test] + fn session_secret_materializes_runtime_profile() { + let profile = sample_postgres_profile(); + let mut secrets = SessionSecretStore::default(); + + secrets + .set("local", "from-session") + .expect("session secret should be set"); + + let materialized = secrets.materialize_profile(&profile); + + assert_eq!( + materialized.target.password.as_deref(), + Some("from-session") + ); + } + + #[test] + fn removing_active_profile_clears_active_pointer() { + let mut catalog = ConnectionProfileCatalog::empty(); + catalog + .upsert_profile(sample_postgres_profile()) + .expect("profile should be inserted"); + catalog + .set_active_profile(Some("local")) + .expect("active profile should be set"); + + let removed = catalog.remove_profile("local"); + + assert!(removed.is_some()); + assert_eq!(catalog.active_profile, None); + } + + fn sample_postgres_profile() -> ConnectionProfile { + ConnectionProfile::new( + "local", + ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 55432, + }, + Some(String::from("dbtool_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"), + Some(String::from("DBTOOL_PASSWORD")), + ) + .expect("profile should be valid") + } + + fn temp_store_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{unique}.json")) + } } diff --git a/crates/db-core/Cargo.toml b/crates/db-core/Cargo.toml index 72f0f3c..2871e9b 100644 --- a/crates/db-core/Cargo.toml +++ b/crates/db-core/Cargo.toml @@ -5,5 +5,11 @@ edition.workspace = true rust-version.workspace = true publish = false +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.145" + [lints] workspace = true diff --git a/crates/db-core/src/lib.rs b/crates/db-core/src/lib.rs index 0e3f425..e7df383 100644 --- a/crates/db-core/src/lib.rs +++ b/crates/db-core/src/lib.rs @@ -1,7 +1,9 @@ +use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{error::Error, fmt}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum DatabaseKind { Postgres, Mysql, @@ -18,18 +20,20 @@ impl DatabaseKind { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum ConnectionTransport { Tcp { host: String, port: u16 }, File { path: PathBuf }, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionTarget { pub kind: DatabaseKind, pub transport: ConnectionTransport, pub database: Option, pub username: Option, + #[serde(skip, default)] pub password: Option, } @@ -142,9 +146,35 @@ impl InspectRequest { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SchemaAvailability { + Ready, + Restricted, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SchemaSummary { pub name: String, + pub availability: SchemaAvailability, + pub note: Option, +} + +impl SchemaSummary { + pub fn ready(name: impl Into) -> Self { + Self { + name: name.into(), + availability: SchemaAvailability::Ready, + note: None, + } + } + + pub fn restricted(name: impl Into, note: impl Into) -> Self { + Self { + name: name.into(), + availability: SchemaAvailability::Restricted, + note: Some(note.into()), + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -381,4 +411,30 @@ mod tests { )) ); } + + #[test] + fn connection_target_serialization_omits_password() { + let mut target = ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 5432, + }, + Some(String::from("qa_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"); + target.password = Some(String::from("secret")); + + let json = serde_json::to_string(&target).expect("target should serialize"); + + assert!(!json.contains("secret")); + assert!(!json.contains("password")); + + let restored: ConnectionTarget = + serde_json::from_str(&json).expect("target should deserialize"); + + assert_eq!(restored.password, None); + assert_eq!(restored.kind, DatabaseKind::Postgres); + } } diff --git a/crates/db-drivers/src/lib.rs b/crates/db-drivers/src/lib.rs index 2016bb8..b70f579 100644 --- a/crates/db-drivers/src/lib.rs +++ b/crates/db-drivers/src/lib.rs @@ -102,23 +102,27 @@ impl DatabaseDriver for PostgresDriver { (None, None) => { let rows = client .query( - "SELECT schema_name \ - FROM information_schema.schemata \ - WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ - ORDER BY schema_name", + "SELECT n.nspname AS schema_name, + has_schema_privilege(n.nspname, 'USAGE') AS has_usage + FROM pg_namespace n + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_toast%' + AND n.nspname NOT LIKE 'pg_temp_%' + ORDER BY n.nspname", &[], ) .map_err(map_postgres_error)?; Ok(InspectResult::Schemas( rows.into_iter() - .map(|row| SchemaSummary { - name: row.get::<_, String>(0), + .map(|row| { + postgres_schema_summary(row.get::<_, String>(0), row.get::<_, bool>(1)) }) .collect(), )) } (Some(schema), None) => { + ensure_postgres_schema_access(&mut client, schema)?; let rows = client .query( "SELECT table_schema, table_name, table_type \ @@ -140,6 +144,7 @@ impl DatabaseDriver for PostgresDriver { )) } (Some(schema), Some(table)) => { + ensure_postgres_schema_access(&mut client, schema)?; let rows = client .query( "SELECT c.column_name, @@ -256,7 +261,7 @@ impl DatabaseDriver for MysqlDriver { Ok(InspectResult::Schemas( rows.into_iter() - .map(|(name,)| SchemaSummary { name }) + .map(|(name,)| SchemaSummary::ready(name)) .collect(), )) } @@ -516,11 +521,7 @@ fn inspect_sqlite_schemas(connection: &SqliteConnection) -> Result(1)?, - }) - }) + .query_map([], |row| Ok(SchemaSummary::ready(row.get::<_, String>(1)?))) .map_err(map_sqlite_inspect_error)? .collect::>>() .map_err(map_sqlite_inspect_error)?; @@ -651,6 +652,41 @@ fn simple_query_result(client: &mut Client, sql: &str) -> Result SchemaSummary { + if has_usage { + SchemaSummary::ready(name) + } else { + SchemaSummary::restricted(name.clone(), restricted_schema_message(&name)) + } +} + +fn ensure_postgres_schema_access(client: &mut Client, schema: &str) -> Result<(), DriverError> { + let row = client + .query_one( + "SELECT EXISTS ( + SELECT 1 + FROM pg_namespace + WHERE nspname = $1 + ) AS schema_exists, + has_schema_privilege($1, 'USAGE') AS has_usage", + &[&schema], + ) + .map_err(map_postgres_error)?; + + let schema_exists = row.get::<_, bool>(0); + let has_usage = row.get::<_, bool>(1); + + if schema_exists && !has_usage { + return Err(DriverError::Inspect(restricted_schema_message(schema))); + } + + Ok(()) +} + +fn restricted_schema_message(schema: &str) -> String { + format!("schema access is restricted: {schema}") +} + fn consume_mysql_query_result( mut result: MySqlQueryResult<'_, '_, '_, T>, ) -> Result { @@ -964,6 +1000,27 @@ mod tests { assert_eq!(rendered, "张敏😀"); } + #[test] + fn postgres_schema_summary_marks_restricted_entries() { + let schema = postgres_schema_summary(String::from("restricted_probe"), false); + + assert_eq!( + schema, + SchemaSummary::restricted( + "restricted_probe", + "schema access is restricted: restricted_probe" + ) + ); + } + + #[test] + fn restricted_schema_message_is_stable() { + assert_eq!( + restricted_schema_message("restricted_probe"), + "schema access is restricted: restricted_probe" + ); + } + #[test] fn sqlite_driver_supports_connect_inspect_and_query() { let unique = SystemTime::now() @@ -995,9 +1052,7 @@ mod tests { .expect("sqlite schemas should load"); assert_eq!( schemas, - InspectResult::Schemas(vec![SchemaSummary { - name: String::from("main"), - }]) + InspectResult::Schemas(vec![SchemaSummary::ready("main")]) ); let tables = DriverRegistry::inspect( diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index 5d009cd..54c0e50 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -5,10 +5,9 @@ services: - "55432:5432" environment: POSTGRES_DB: dbtool_demo - POSTGRES_USER: dbtool - POSTGRES_PASSWORD: dbtool + POSTGRES_PASSWORD: dbtoolroot healthcheck: - test: ["CMD-SHELL", "pg_isready -U dbtool -d dbtool_demo"] + test: ["CMD-SHELL", "pg_isready -U postgres -d dbtool_demo"] interval: 5s timeout: 5s retries: 20 diff --git a/examples/README.md b/examples/README.md index f976e3c..1dd50c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,12 @@ These assets give backend, frontend, and QA one small shared dataset for Postgre - `examples/fixtures/mysql/init.sql` 会显式授予 `dbtool` 用户对 `qa_demo` 的权限 - 这样可避免 demo 容器默认库名与 QA 目标库名不一致时,`dbtool` 无法访问 `qa_demo` +## PostgreSQL Bootstrap Note + +- `docker-compose.demo.yml` 与 `scripts/tui/demo-stack.sh` 现在保留 `postgres` 作为 demo 超级用户,密码为 `dbtoolroot` +- `examples/fixtures/postgres/init.sql` 会把 `dbtool` 收敛为 `NOSUPERUSER` 的 app 用户,并只授予 `dbtool_demo.qa_demo` 的读权限 +- 若容器是在旧配置(`POSTGRES_USER=dbtool`)下初始化的,需要先 `docker compose -f docker-compose.demo.yml down -v` 或 `scripts/tui/demo-stack.sh reset` 再重建,否则旧卷里的超级权限不会自动消失 + ## SQLite Bootstrap Note - `examples/scripts/bootstrap-sqlite.sh` 现支持 `sqlite3`、`node:sqlite` 或 `python3 sqlite3` 三种 seed 路径 diff --git a/examples/fixtures/postgres/init.sql b/examples/fixtures/postgres/init.sql index 4c333d3..1c0bdf7 100644 --- a/examples/fixtures/postgres/init.sql +++ b/examples/fixtures/postgres/init.sql @@ -1,5 +1,31 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dbtool') THEN + CREATE ROLE dbtool + LOGIN + PASSWORD 'dbtool' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + INHERIT; + ELSE + ALTER ROLE dbtool + WITH LOGIN + PASSWORD 'dbtool' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + INHERIT; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE dbtool_demo TO dbtool; + DROP SCHEMA IF EXISTS qa_demo CASCADE; -CREATE SCHEMA qa_demo; +CREATE SCHEMA qa_demo AUTHORIZATION postgres; +GRANT USAGE ON SCHEMA qa_demo TO dbtool; +ALTER ROLE dbtool IN DATABASE dbtool_demo SET search_path TO qa_demo, public; SET search_path TO qa_demo; CREATE TABLE accounts ( @@ -30,3 +56,6 @@ INSERT INTO tickets (id, account_id, title, status, notes, amount_cents, created (102, 1, 'csv export mismatch', 'closed', 'fixed after re-run', 1500, '2026-03-21T11:15:00Z'), (103, 2, '权限验证', 'open', 'needs DBA review', 300, '2026-03-22T15:30:00Z'), (104, 3, 'emoji smoke 😀', 'open', NULL, 42, '2026-03-23T07:05:00Z'); + +GRANT SELECT ON ALL TABLES IN SCHEMA qa_demo TO dbtool; +ALTER DEFAULT PRIVILEGES IN SCHEMA qa_demo GRANT SELECT ON TABLES TO dbtool; diff --git a/examples/scripts/bootstrap-postgres.sh b/examples/scripts/bootstrap-postgres.sh index 0a80a68..97307fb 100755 --- a/examples/scripts/bootstrap-postgres.sh +++ b/examples/scripts/bootstrap-postgres.sh @@ -5,7 +5,7 @@ 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_USER="${POSTGRES_USER:-postgres}" POSTGRES_DB="${POSTGRES_DB:-dbtool_demo}" if docker compose version >/dev/null 2>&1; then diff --git a/gui/README.md b/gui/README.md index de8b997..ba5e24c 100644 --- a/gui/README.md +++ b/gui/README.md @@ -34,12 +34,13 @@ ## 原型包含内容 - 连接管理、schema browser、query editor、results、export、inspector 的稳定工作台分区 +- 明确的 `connect -> inspect -> query -> export` 引导式评审轨道 - 按连接切换不同的 schema catalog、SQL 草稿标签、结果列和默认导出路径 - `Running` / `Success` / `Empty` / `Error` / `Exported` 五种可预览执行态 - Light / Dark 主题切换 - schema 搜索过滤、连接切换、结果 / 历史 / 导出 / 问题面板切换 - 当前界面状态到共享结构化契约的映射预览,其中 `running` 明确标记为前端暂态 -- 键盘快捷路径:`1` / `2` / `3` / `4` / `5`、`R`、`T`、`/`、`⌘/Ctrl+K` +- 键盘快捷路径:`C` / `I` / `Q` / `E`、`1` / `2` / `3` / `4` / `5`、`R`、`T`、`/`、`⌘/Ctrl+K` ## 查看方式 @@ -65,6 +66,16 @@ http://127.0.0.1:4173 当前 `/` 会落到 `gui/index.html`,再从入口页进入工作台原型。 +## 建议评审路径 + +推荐按以下顺序评审: + +1. 用 `C` 或顶部 workflow strip 查看 `Connect`,确认目标卡片、健康态和工作区摘要 +2. 用 `I` 查看 `Inspect`,确认 schema tree、selection 和 inspector 同步 +3. 用 `Q` 查看 `Query`,确认 SQL 草稿、执行 banner 和结果区处于同一上下文 +4. 用 `R`、`1`–`5` 或 `⌘/Ctrl+K` 预览不同执行态 +5. 用 `E` 查看 `Export`,确认默认路径、empty guidance 与 blocked export 行为 + ## 界面验收 验收人应重点确认: @@ -76,6 +87,7 @@ http://127.0.0.1:4173 5. 切换连接后,顶部摘要、中部工作区和右侧 inspector 是否同步更新 6. 切换连接后,schema tree、query tab 标签、结果列与默认导出路径是否也同步切换到对应驱动上下文 7. Light / Dark 两种主题下信息密度、对比度和可读性是否仍然稳定 -8. `⌘/Ctrl+K` 快捷操作面板是否能帮助 QA 快速预览状态与连接切换 -9. 文档中的后端契约需求是否仍可映射到 CLI 已定义产品概念 -10. `Contract` 面板是否能帮助 QA 与 Backend 快速核对 `query` / `export` envelope,以及 `running` 暂态边界 +8. 顶部 workflow strip 和 `C` / `I` / `Q` / `E` 是否能帮助 QA 明确当前正在评审哪一步主流程 +9. `⌘/Ctrl+K` 快捷操作面板是否能帮助 QA 快速预览状态与连接切换 +10. 文档中的后端契约需求是否仍可映射到 CLI 已定义产品概念 +11. `Contract` 面板是否能帮助 QA 与 Backend 快速核对 `query` / `export` envelope,以及 `running` 暂态边界 diff --git a/gui/index.html b/gui/index.html index 1413cd1..dbadd77 100644 --- a/gui/index.html +++ b/gui/index.html @@ -54,6 +54,16 @@ +
+

Review Path

+

可按主流程逐步评审

+
    +
  • Connect → Inspect → Query → Export
  • +
  • 工作流轨道与高亮区域同步
  • +
  • `C` / `I` / `Q` / `E` 可快速切换评审步骤
  • +
+
+

Acceptance

QA 重点确认

diff --git a/gui/prototype/app.js b/gui/prototype/app.js index 200af61..e9d6450 100644 --- a/gui/prototype/app.js +++ b/gui/prototype/app.js @@ -495,6 +495,8 @@ limit 25;`, const connectionCards = document.querySelectorAll(".connection-card"); const bottomTabs = document.querySelectorAll(".bottom-tab"); const tabContents = document.querySelectorAll(".tab-content"); +const workflowStepButtons = document.querySelectorAll("[data-workflow-step]"); +const reviewPanels = document.querySelectorAll("[data-review-panel]"); const activeConnection = document.getElementById("active-connection"); const driverPill = document.getElementById("driver-pill"); @@ -556,6 +558,7 @@ 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]"); +const workflowCopy = document.getElementById("workflow-copy"); const appState = { connection: "pg-prod-readonly", @@ -563,9 +566,17 @@ const appState = { selection: "public.orders", activeState: "success", activeTab: "results", + workflowStep: "connect", runTimer: null, }; +const workflowStepCopy = { + connect: "当前聚焦 Connect:先确认活动目标、环境标签和连接摘要清晰可见。", + inspect: "当前聚焦 Inspect:核对 schema browser、当前对象和 inspector 元数据保持同步。", + query: "当前聚焦 Query:确认 SQL 草稿、执行反馈和结果区在同一工作台上下文里。", + export: "当前聚焦 Export:复核默认导出路径、空结果说明和错误态禁用约束。", +}; + function getActiveConnectionCard() { return document.querySelector(`.connection-card[data-connection="${appState.connection}"]`); } @@ -618,6 +629,20 @@ function setActiveTab(tabName) { }); } +function renderWorkflowStep() { + workflowStepButtons.forEach((button) => { + const isActive = button.dataset.workflowStep === appState.workflowStep; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); + + reviewPanels.forEach((panel) => { + panel.classList.toggle("is-review-focus", panel.dataset.reviewPanel === appState.workflowStep); + }); + + workflowCopy.textContent = workflowStepCopy[appState.workflowStep]; +} + function renderToolbarTabs() { const fixture = getWorkspaceFixture(); @@ -1095,6 +1120,7 @@ function renderAll() { renderConnection(); renderSelection(); renderThemeLabel(); + renderWorkflowStep(); renderQuery(); setActiveTab(appState.activeTab); } @@ -1124,6 +1150,26 @@ function syncSelection(selection) { renderSelection(); } +function setWorkflowStep(step, { syncTab = false, status = null } = {}) { + appState.workflowStep = step; + + if (syncTab) { + if (step === "query") { + setActiveTab("results"); + } + + if (step === "export") { + setActiveTab("export"); + } + } + + renderWorkflowStep(); + + if (status) { + statusMessage.textContent = status; + } +} + function setQuery(queryKey, syncState = true) { const preset = getQueryPreset(queryKey); appState.query = queryKey; @@ -1136,6 +1182,7 @@ function setQuery(queryKey, syncState = true) { function startRunPreview() { clearRunTimer(); + setWorkflowStep("query", { syncTab: true }); appState.activeState = "running"; setActiveTab("results"); renderState(); @@ -1158,7 +1205,9 @@ connectionCards.forEach((card) => { schemaSearch.value = ""; setActiveTab("results"); renderAll(); - statusMessage.textContent = `${card.dataset.connection} loaded. Catalog, draft labels, and inspector now reflect the active target.`; + setWorkflowStep("connect", { + status: `${card.dataset.connection} loaded. Catalog, draft labels, and inspector now reflect the active target.`, + }); }); }); @@ -1170,7 +1219,9 @@ schemaTree.addEventListener("click", (event) => { } syncSelection(item.dataset.selection); - statusMessage.textContent = `Selection changed to ${item.dataset.selection}. Inspector and workspace stay aligned.`; + setWorkflowStep("inspect", { + status: `Selection changed to ${item.dataset.selection}. Inspector and workspace stay aligned.`, + }); }); toolbarTabsContainer.addEventListener("click", (event) => { @@ -1181,6 +1232,7 @@ toolbarTabsContainer.addEventListener("click", (event) => { } clearRunTimer(); + setWorkflowStep("query", { syncTab: true }); setActiveTab("results"); setQuery(tab.dataset.query, true); }); @@ -1191,6 +1243,21 @@ bottomTabs.forEach((tab) => { }); }); +workflowStepButtons.forEach((button) => { + button.addEventListener("click", () => { + const step = button.dataset.workflowStep; + const syncTab = step === "query" || step === "export"; + const statusMap = { + connect: "Connect review active. Confirm target health, environment label, and workspace summary.", + inspect: "Inspect review active. Check schema tree, selection, and inspector metadata alignment.", + query: "Query review active. Validate SQL draft, execution banner, and results context together.", + export: "Export review active. Check default path, blocked states, and empty-result guidance.", + }; + + setWorkflowStep(step, { syncTab, status: statusMap[step] }); + }); +}); + runQueryButton.addEventListener("click", () => { startRunPreview(); }); @@ -1205,6 +1272,7 @@ cycleStateButton.addEventListener("click", () => { function activateExport(format) { clearRunTimer(); + setWorkflowStep("export", { syncTab: true }); if (appState.activeState === "running") { renderState(); @@ -1321,10 +1389,32 @@ document.addEventListener("keydown", (event) => { return; } + if (!commandPalette.hidden) { + return; + } + if (event.target === schemaSearch) { return; } + const workflowHotkeyMap = { + c: "connect", + i: "inspect", + q: "query", + e: "export", + }; + + const workflowStep = workflowHotkeyMap[event.key.toLowerCase()]; + + if (workflowStep && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + setWorkflowStep(workflowStep, { + syncTab: workflowStep === "query" || workflowStep === "export", + status: `${workflowStep[0].toUpperCase()}${workflowStep.slice(1)} review active.`, + }); + return; + } + if (event.key === "/") { event.preventDefault(); schemaSearch.focus(); diff --git a/gui/prototype/index.html b/gui/prototype/index.html index f6addb4..ea4c17a 100644 --- a/gui/prototype/index.html +++ b/gui/prototype/index.html @@ -56,9 +56,37 @@
+
+
+

Guided Review

+

MVP Workflow

+
+
+ + + + +
+

+ 当前聚焦 Connect:先确认活动目标、环境标签和连接摘要清晰可见。 +

+
+
-
+
@@ -229,7 +257,7 @@
-
+
@@ -291,8 +319,12 @@