You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
cleanup(engine): make process_command stdout-clean (drop REPL-only prints) (#76)
The engine's `process_command` had three direct stdout writes that
made sense in a REPL but corrupted any other channel an embedder
might be using stdout for:
src/sql/mod.rs:150 — let _ = table.print_table_schema(); (CREATE)
src/sql/mod.rs:208 — db_table.print_table_data(); (INSERT)
src/sql/mod.rs:224 — print!("{rendered}"); (SELECT)
Plus a leftover debug print in src/sql/parser/create.rs:190 dumping
unhandled `sqlparser::ast::TableConstraint` debug reprs to stdout.
This is the issue PR #73 papered over with the dup2(2,1) dance in
sqlrite-mcp/src/stdio_redirect.rs. That fix is still in place
(belt-and-suspenders against future regressions), but the engine now
behaves correctly without it — the SDKs (Python, Node, Go, WASM,
FFI), the Tauri desktop, and any future embedder all benefit.
## How
Refactored process_command to return a richer struct, with a
backwards-compat wrapper:
pub struct CommandOutput {
pub status: String, // "INSERT Statement executed.", etc.
pub rendered: Option<String>, // SELECT prettytable; None otherwise
}
pub fn process_command(query, db) -> Result<String>
// backwards-compat: returns just .status
pub fn process_command_with_render(query, db) -> Result<CommandOutput>
// new: rich return, never writes to stdout
The REPL (`src/main.rs`) calls `_with_render` and prints rendered
above status — same UX as before. The .ask meta-command does the
same and concatenates the two pieces into the single String it
bubbles up to the REPL's outer dispatch loop.
The CREATE-TABLE schema dump and INSERT row dump aren't preserved —
they were small UX niceties that interactive REPL users rarely need
(the schema you just CREATEd, the row you just INSERTed). The INSERT
case was actively bad UX on any non-trivial table — it dumped the
entire table after every insert. Both gone.
`sqlrite::process_command` keeps its existing signature for
backwards compatibility — every existing call site (Connection::execute,
the SDK FFI shims, the .ask handler's inline runner, the engine's
own tests) keeps working unchanged.
## Verified
- `cargo build --workspace` clean
- `cargo test --workspace --exclude sqlrite-{python,nodejs,desktop}` —
330+ tests across engine + sqlrite-mcp + sqlrite-ask + sqlrite-ffi,
all passing (engine's 251-test integration suite covers the
process_command paths most directly affected)
- REPL smoke: CREATE / INSERT / SELECT round-trip prints rendered
table above status, no surprises
- MCP smoke: same round-trip via JSON-RPC over stdio — `cat 2>/dev/null`
filtered stderr is now empty (was prettytable noise before;
stdio_redirect was the only thing keeping it off the protocol channel)
## Docs
- `docs/architecture.md` — updates the engine module map entries for
src/lib.rs and src/sql/mod.rs to mention the new types + the
"never writes to stdout" guarantee
- `docs/sql-engine.md` — entry-point section now shows both functions
+ the CommandOutput shape; explains the rendered-vs-status split
- `docs/mcp.md` — security-notes section reframes stdio_redirect as
defense-in-depth now that the engine is clean
- `sqlrite-mcp/src/stdio_redirect.rs` — module-level comment rewritten
to call out the engine fix + the three concrete future-regression
scenarios the redirect protects against
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/architecture.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -89,13 +89,13 @@ The engine never depends on the SDK crates; the SDK crates each depend on the en
89
89
| Module | What it owns |
90
90
|---|---|
91
91
|[`src/main.rs`](../src/main.rs)| Binary entry: init env_logger, build rustyline editor, run the REPL loop, route input to either the meta or SQL dispatcher |
92
-
|[`src/lib.rs`](../src/lib.rs)| Library entry: re-exports `Connection`, `Statement`, `Rows`, `Value`, `Database`, `process_command`, the `ask` module (when feature on), etc. — the stable public surface every SDK binds against |
92
+
|[`src/lib.rs`](../src/lib.rs)| Library entry: re-exports `Connection`, `Statement`, `Rows`, `Value`, `Database`, `process_command` / `process_command_with_render` / `CommandOutput`, the `ask` module (when feature on), etc. — the stable public surface every SDK binds against |
93
93
|[`src/connection.rs`](../src/connection.rs)|`Connection` / `Statement` / `Rows` / `Row` / `OwnedRow` / `FromValue` — the Phase 5a public API |
94
94
|[`src/ask/`](../src/ask/)| Engine integration with `sqlrite-ask`: `ConnectionAskExt`, `ask_with_database`, the `schema::dump_schema_for_database` helper. The `schema` submodule is always available; the rest is gated behind the `ask` feature. Phase 7g.2. |
95
95
|[`src/repl/`](../src/repl/)|`REPLHelper` (implements rustyline's `Helper` trait: completer, hinter, highlighter, validator). Also `get_config` and `get_command_type`|
96
96
|[`src/meta_command/`](../src/meta_command/)|`MetaCommand` enum, parsing (`.open FOO.db` → `Open(PathBuf)`, `.ask <Q>` → `Ask(String)`), and dispatch to persistence + ask helpers |
97
97
|[`src/error.rs`](../src/error.rs)|`SQLRiteError` (thiserror-derived), `Result<T>` alias, hand-rolled `PartialEq` that handles `io::Error`|
98
-
|[`src/sql/mod.rs`](../src/sql/mod.rs)|`SQLCommand` classifier, `process_command` — the top-level entry that parses a SQL string and routes to the right executor. Also triggers auto-save. |
98
+
|[`src/sql/mod.rs`](../src/sql/mod.rs)|`SQLCommand` classifier, `process_command`/ `process_command_with_render`— the top-level entries that parse a SQL string and route to the right executor. Also triggers auto-save. **Never writes to stdout** — for SELECT statements, the rendered prettytable comes back inside `CommandOutput.rendered` so the REPL can print it (the engine itself doesn't); the SDK / FFI / MCP callers ignore it. |
99
99
|[`src/sql/parser/`](../src/sql/parser/)| Takes a `sqlparser::ast::Statement` and produces internal query structs (`CreateQuery`, `InsertQuery`, `SelectQuery`) with only the fields we actually use |
100
100
|[`src/sql/executor.rs`](../src/sql/executor.rs)|`execute_select`, `execute_delete`, `execute_update`, plus the shared expression evaluator `eval_expr` / `eval_predicate`. Also the bounded-heap top-k optimization (Phase 7c) and the HNSW probe shortcut (Phase 7d.2). |
Copy file name to clipboardExpand all lines: docs/mcp.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -309,7 +309,7 @@ Stderr is reserved for diagnostics (panics, the MCP startup banner). Anything wr
309
309
310
310
-**Logging hygiene:** the `query`, `execute`, and `ask` tools receive arbitrary user-supplied SQL and questions. The server emits these to stderr only on errors (so the MCP log pane is useful for debugging) — but if you wrap `sqlrite-mcp`'s stderr in your own logging stack, treat the contents as potentially sensitive.
311
311
312
-
-**Stdout is owned by the protocol.** The binary redirects process fd 1 to fd 2 at startup so any errant `print!` from anywhere in the dep tree goes to stderr instead of corrupting the JSON-RPC channel. See `sqlrite-mcp/src/stdio_redirect.rs` for the dance.
312
+
-**Stdout is owned by the protocol.**As of the engine-stdout-pollution cleanup, the SQLRite engine itself doesn't print to stdout — the REPL-convenience prints (CREATE schema, INSERT row dump, SELECT result table) all moved out: SELECT's rendered table comes back inside [`CommandOutput::rendered`](../src/sql/mod.rs) for the REPL to print itself, the others were dropped entirely. The MCP binary additionally redirects process fd 1 → fd 2 at startup as **defense in depth** — protects against future regressions if a contributor (or a transitive dep) ever reintroduces a stray `print!`. See `sqlrite-mcp/src/stdio_redirect.rs` for the dance.
pubrendered:Option<String>, // Some(_) only for SELECT
22
+
}
11
23
```
12
24
13
-
GivenarawlineofSQL (e.g. `"SELECT name FROM users WHERE age > 25;"`) andthein-memorydatabase, itreturnseitherahuman-readablestatusmessageoratypederror.
25
+
Given a raw line of SQL (e.g. `"SELECT name FROM users WHERE age > 25;"`) and the in-memory database, both functions return either a human-readable status (and, for SELECT, a rendered prettytable) or a typed error. **Neither writes to stdout** — the REPL is the only consumer that prints anything; everyone else (Tauri desktop, SDKs, the MCP server) just reads the returned struct.
14
26
15
27
The function's shape:
16
28
@@ -19,7 +31,7 @@ The function's shape:
19
31
3. Classify as read-only vs. writing.
20
32
4. Match on `Statement::*` and dispatch to the appropriate executor.
21
33
5. If the statement writes and the DB is file-backed, auto-save.
22
-
6.Returnthestatusstring.
34
+
6. Return `CommandOutput { status, rendered }`. (`process_command` is a backwards-compat wrapper that just returns `.status`.)
0 commit comments