feat: support explicit read-write database connections

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:57:51 +02:00
parent 2aac7361d6
commit 4f472b55f3
6 changed files with 617 additions and 98 deletions
+55 -23
View File
@@ -12,12 +12,12 @@ use crossterm::event::{
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection
const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in SQL writes
Usage:
dbdash [POSTGRES_CONNECTION_STRING]
dbdash --ca-bundle PATH POSTGRES_CONNECTION_STRING
dbdash --sqlite PATH
dbdash [--read-write] [POSTGRES_CONNECTION_STRING]
dbdash [--read-write] --ca-bundle PATH POSTGRES_CONNECTION_STRING
dbdash [--read-write] --sqlite PATH
dbdash --help
Run without a connection argument to open the saved database list:
@@ -30,6 +30,14 @@ SQLite paths can be relative or absolute; quote paths containing spaces.
SQLite paths expand leading ~, $VAR, and ${VAR}; undefined variables are errors.
Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
Connections default to READ ONLY, including existing saved configurations.
Choose Access in the connection window to save READ ONLY or READ WRITE per database.
For an unsaved CLI session, opt in explicitly:
cargo run -- --read-write --sqlite ./database.sqlite
--read-write requires a direct connection; it does not change saved profiles.
The header and connected sidebar badge reflect the live session, not saved edits.
Changing saved Access requires reconnecting (R on the saved database or a child).
PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections.
@@ -68,12 +76,13 @@ Navigation:
i Show configured database info (Esc/Enter closes; passwords omitted)
Works on a saved database entry or any of its table/view children.
r Refresh relations and the active relation
Connected databases have a trailing * and show their tables/views beneath them.
Databases always show [RO]/[RW], even when their labels truncate; connected ones
have a trailing * and show their tables/views beneath them. R reconnects a saved database.
Unnamed SQLite entries show only the filename; the info window shows the full path.
Enter on the connected database collapses/expands it without reconnecting.
Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump.
Query Insert mode and connection/password fields keep ? as literal text.
Full-width bars show dbdash/READ ONLY above and mode/database below.
Full-width bars show dbdash/live READ ONLY or READ WRITE above and mode/database below.
Saved PostgreSQL connections show database · user@host:port beside the mode,
regardless of custom sidebar names; passwords are never included.
The database provider sits immediately left of the bottom-right position badge.
@@ -94,23 +103,29 @@ Navigation:
Connection/password fields remain direct-entry; query paste requires INSERT.
SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect,
including quoted identifiers, literals, and multiline comments/strings as you type.
F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
F5 / Ctrl-Enter executes one SQL statement on the current connection, showing up
to 100 result rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
READ ONLY accepts SELECT/CTE reads; READ WRITE also supports SQL writes.
Each successful statement commits atomically; failures roll back. Explicit transaction
control and multi-statement scripts are rejected. Writes report affected-row counts;
RETURNING displays result rows (up to 100) without limiting the rows written.
Cells are never edited inline: use INSERT, UPDATE, DELETE, etc. in the SQL editor.
Ctrl-Enter needs terminal support for enhanced keyboard reporting; use F5 otherwise.
Failed queries keep the previous results. Opening a relation restores browsing
without clearing the query text. Query text is not saved across connections.
q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT)
New database window: Up/Down chooses a provider; Enter starts editing.
Tab/Shift-Tab changes fields; Left/Right changes TLS or the CA bundle when focused.
Tab/Shift-Tab changes fields; Left/Right changes TLS, CA bundle, or Access when focused.
Enter saves without connecting, including when the database is offline.
Optional Name controls the sidebar label; leave it blank for the default label.
e on a saved database or its child edits the configuration without connecting.
Enter saves edits; Esc discards them. Connected sessions keep their actual identity.
Enter saves edits; Esc discards them. Connected sessions keep their actual identity
and access mode until R reconnects; Enter on an active database still only expands it.
d / Delete on a saved database asks for y to remove its configuration (n/Esc cancels).
Database files are never deleted. Removing the active profile keeps its session open.
Select a saved database and press Enter to connect. PostgreSQL opens an editable
connection window, initially focused on the password. Tab/Shift-Tab changes fields;
Left/Right changes TLS or the CA bundle when focused. Enter connects and updates that saved entry
Left/Right changes TLS, CA bundle, or Access when focused. Enter connects and updates that saved entry
only on success; failures keep the previous settings and connection. Esc discards edits.
Passwords are masked, never saved, and clear on success or cancellation.
Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords.
@@ -121,10 +136,12 @@ Navigation:
back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute.
CLI connections are not saved. Failed switches keep the current database open.
PostgreSQL reads are enforced read-only and have a 5-second query timeout.
SQLite files are opened read-only, with a 5-second lock wait but no query timeout.
The interface waits for reads to complete. NULL cells are dimmed; SQLite BLOBs
are shown as X'hex'. Only SQLite's main database is inspected.
PostgreSQL statements have a 5-second timeout. READ ONLY sessions enforce read-only
transactions; READ WRITE statements commit on success. SQLite READ ONLY opens files
read-only; READ WRITE permits writes to existing files. Missing files are never created.
SQLite has a 5-second lock wait but no query timeout. The interface waits for statements
to complete. NULL cells are dimmed; SQLite BLOBs are shown as X'hex'.
Only SQLite's main database is inspected.
Pages use primary-key order, or physical row order for keyless tables when available.
Keyless views have no guaranteed order; concurrent changes can shift pages.
"#;
@@ -152,13 +169,27 @@ fn run() -> Result<()> {
if std::env::var_os("PGSSLROOTCERT").is_some() {
return Err("Unset PGSSLROOTCERT and select a CA bundle per connection, or use --ca-bundle PATH on the CLI.".into());
}
let root_cert = if argument.as_ref().is_some_and(|arg| arg == "--ca-bundle") {
let path = args.next().ok_or("--ca-bundle requires a PEM file path")?;
argument = Some(args.next().ok_or("--ca-bundle requires a PostgreSQL connection string")?);
Some(path)
} else {
None
};
let mut access = db::AccessMode::ReadOnly;
let mut root_cert = None;
loop {
match argument.as_deref().and_then(|arg| arg.to_str()) {
Some("--read-write") => access = db::AccessMode::ReadWrite,
Some("--ca-bundle") => {
if root_cert.is_some() {
return Err("--ca-bundle may only be specified once".into());
}
root_cert = Some(args.next().ok_or("--ca-bundle requires a PEM file path")?);
}
_ => break,
}
argument = args.next();
}
if root_cert.is_some() && argument.is_none() {
return Err("--ca-bundle requires a PostgreSQL connection string".into());
}
if access == db::AccessMode::ReadWrite && argument.is_none() {
return Err("--read-write requires a direct database connection; change saved Access in the connection window".into());
}
let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
if sqlite && root_cert.is_some() {
return Err("--ca-bundle is only supported for PostgreSQL".into());
@@ -180,13 +211,14 @@ fn run() -> Result<()> {
let database = connection
.map(|connection| {
if sqlite {
db::Database::open_sqlite(Path::new(&connection))
db::Database::open_sqlite(Path::new(&connection), access)
} else {
db::Database::connect(
connection
.to_str()
.ok_or("connection string must be UTF-8")?,
root_cert.as_deref().map(Path::new),
access,
)
}
})