Skip to main content

rws_proxy/db_cli/
mod.rs

1//! `rws db migrate|rollback|status|seed` (18.30.0, #91): the project's
2//! migrations and seeds from the command line — the same operations, and
3//! for `status` the same JSON, as the dev MCP server's `migrations`,
4//! `migrations_run` and `migrations_rollback_last` tools (they share
5//! `dev_mcp::db`).
6//!
7//! - **Database**: `--database-url <url>`, else `DATABASE_URL`, else the
8//!   `RWS_DB_*` variables `DbPool::from_env` reads. URLs are sqlx's:
9//!   `sqlite:app.db` (`?mode=rwc` creates the file), `postgres://…`,
10//!   `mysql://…`.
11//! - **Migrations**: the `migrations` path of the project's `rws.app.toml`
12//!   (`migrations/` without one), under `--dir` (default `.`);
13//!   `--migrations <dir>` overrides it.
14//! - **Seeds**: `seeds/` beside the migrations directory (`migrations/` →
15//!   `seeds/`, `db/migrations/` → `db/seeds/`); `--seeds <dir>` overrides
16//!   it. Every `*.sql` file runs, in name order, each in its own
17//!   transaction, every time — see `DbPool::seed`.
18//!
19//! The `rws` binary has the command in every build, but it only reaches a
20//! database when built with a `model-*` feature (`cargo install
21//! rust-web-server --features model-sqlite,model-postgres,model-mysql`);
22//! otherwise it says so and exits with [`EXIT_USAGE`].
23
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27use crate::app_manifest::AppManifest;
28#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
29use crate::model::DbPool;
30
31#[cfg(test)]
32mod tests;
33
34/// Done.
35pub const EXIT_OK: i32 = 0;
36/// The database or a migration failed (connection, SQL error, missing
37/// `.down.sql`, unreadable directory): see the message.
38pub const EXIT_FAILED: i32 = 1;
39/// Bad arguments, or a build without a database driver.
40pub const EXIT_USAGE: i32 = 2;
41
42const USAGE: &str = "\
43Usage: rws db <command> [--database-url <url>] [--dir <project>]
44                        [--migrations <dir>] [--seeds <dir>] [--json]
45
46Commands:
47  migrate        apply every pending migration, each in its own transaction
48  rollback [N]   undo the last N applied migrations (default 1) with their .down.sql files
49  status         list the migrations in order: applied or pending, and whether each has a .down.sql
50  seed           run every *.sql seed file, in name order, each in its own transaction
51
52Database: --database-url (sqlite:app.db, postgres://user:pass@host/db,
53mysql://user:pass@host/db), else DATABASE_URL, else RWS_DB_BACKEND, RWS_DB_NAME,
54RWS_DB_HOST, RWS_DB_PORT, RWS_DB_USER, RWS_DB_PASSWORD.
55Migrations: the `migrations` path of rws.app.toml under --dir (migrations/ by
56default). Seeds: seeds/ beside the migrations directory (db/migrations ->
57db/seeds). Seeds run again every time: write them so they can.
58--json prints the result (or {\"error\": …}) for agents.
59Exit codes: 0 done, 1 database or migration error, 2 usage.
60";
61
62/// `rws db …` asked for: the arguments after `db`.
63pub fn requested(args: &[String]) -> Option<&[String]> {
64    (args.get(1).map(String::as_str) == Some("db")).then(|| &args[2..])
65}
66
67/// What to do.
68#[derive(Debug, Clone, PartialEq, Eq)]
69enum Command {
70    Migrate,
71    Rollback(usize),
72    Status,
73    Seed,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct Cli {
78    command: Command,
79    database_url: Option<String>,
80    dir: PathBuf,
81    migrations: Option<PathBuf>,
82    seeds: Option<PathBuf>,
83    json: bool,
84}
85
86enum Parsed {
87    Help,
88    Run(Cli),
89}
90
91/// Run `rws db <args>` (the arguments after `db`), writing to `out` and
92/// `err`; answers the exit code ([`EXIT_OK`], [`EXIT_FAILED`],
93/// [`EXIT_USAGE`]).
94pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
95    let json = args.iter().any(|a| a == "--json");
96    let cli = match parse(args) {
97        Ok(Parsed::Help) => {
98            let _ = write!(out, "{USAGE}");
99            return EXIT_OK;
100        }
101        Ok(Parsed::Run(cli)) => cli,
102        Err(message) => return fail(&format!("{message}\n(rws db --help lists the commands)"), &message, json, EXIT_USAGE, out, err),
103    };
104    let (migrations, seeds) = match directories(&cli) {
105        Ok(dirs) => dirs,
106        Err(message) => return fail(&message, &message, json, EXIT_FAILED, out, err),
107    };
108    execute(&cli, &migrations, &seeds, out, err)
109}
110
111fn fail(text: &str, message: &str, json: bool, code: i32, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
112    if json {
113        let _ = writeln!(out, r#"{{"error":{}}}"#, quoted(message));
114    } else {
115        let _ = writeln!(err, "rws db: {text}");
116    }
117    code
118}
119
120fn quoted(s: &str) -> String {
121    format!("\"{}\"", crate::core::json::escape(s))
122}
123
124fn parse(args: &[String]) -> Result<Parsed, String> {
125    let mut positional: Vec<String> = Vec::new();
126    let mut cli = Cli { command: Command::Status, database_url: None, dir: PathBuf::from("."), migrations: None, seeds: None, json: false };
127    let mut i = 0;
128    while i < args.len() {
129        let arg = args[i].as_str();
130        i += 1;
131        if arg == "-h" || arg == "--help" || (arg == "help" && positional.is_empty()) {
132            return Ok(Parsed::Help);
133        }
134        if !arg.starts_with("--") {
135            positional.push(arg.to_string());
136            continue;
137        }
138        let (flag, inline) = match arg.split_once('=') {
139            Some((f, v)) => (f, Some(v.to_string())),
140            None => (arg, None),
141        };
142        let mut value = |name: &str| -> Result<String, String> {
143            if let Some(v) = inline.clone() {
144                return Ok(v);
145            }
146            let v = args.get(i).cloned().ok_or_else(|| format!("{name} needs a value"))?;
147            i += 1;
148            Ok(v)
149        };
150        match flag {
151            "--json" => cli.json = true,
152            "--database-url" => cli.database_url = Some(value("--database-url")?),
153            "--dir" => cli.dir = PathBuf::from(value("--dir")?),
154            "--migrations" => cli.migrations = Some(PathBuf::from(value("--migrations")?)),
155            "--seeds" => cli.seeds = Some(PathBuf::from(value("--seeds")?)),
156            other => return Err(format!("unknown option {other}")),
157        }
158    }
159    let Some(command) = positional.first() else {
160        return Err("name a command: migrate, rollback, status or seed".to_string());
161    };
162    let extra = |at: usize| positional.get(at).map(|extra| format!("rws db {command} takes no `{extra}`"));
163    cli.command = match command.as_str() {
164        "migrate" | "status" | "seed" => {
165            if let Some(e) = extra(1) {
166                return Err(e);
167            }
168            match command.as_str() {
169                "migrate" => Command::Migrate,
170                "status" => Command::Status,
171                _ => Command::Seed,
172            }
173        }
174        "rollback" => {
175            if let Some(e) = extra(2) {
176                return Err(e);
177            }
178            let n = match positional.get(1) {
179                None => 1,
180                Some(n) => match n.parse::<usize>() {
181                    Ok(n) if n > 0 => n,
182                    _ => return Err(format!("rws db rollback {n}: give how many migrations to undo, a number from 1")),
183                },
184            };
185            Command::Rollback(n)
186        }
187        other => return Err(format!("unknown command `{other}`: use migrate, rollback, status or seed")),
188    };
189    Ok(Parsed::Run(cli))
190}
191
192/// The migrations and seeds directories for `cli`.
193fn directories(cli: &Cli) -> Result<(String, String), String> {
194    let migrations = match &cli.migrations {
195        Some(dir) => dir.clone(),
196        None => {
197            let manifest = AppManifest::load(&cli.dir).map_err(|e| e.to_string())?;
198            cli.dir.join(&manifest.paths.migrations)
199        }
200    };
201    let seeds = match &cli.seeds {
202        Some(dir) => dir.clone(),
203        None => seeds_beside(&migrations),
204    };
205    Ok((path_text(&migrations), path_text(&seeds)))
206}
207
208/// `seeds/` in the directory that holds `migrations`.
209fn seeds_beside(migrations: &Path) -> PathBuf {
210    let trimmed: PathBuf = migrations.components().collect();
211    match trimmed.parent() {
212        Some(parent) if !parent.as_os_str().is_empty() => parent.join("seeds"),
213        _ => PathBuf::from("seeds"),
214    }
215}
216
217fn path_text(path: &Path) -> String {
218    path.to_string_lossy().into_owned()
219}
220
221#[cfg(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
222fn execute(cli: &Cli, _migrations: &str, _seeds: &str, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
223    let message = "this rws was built without a database driver — install one with them: \
224                   cargo install rust-web-server --features model-sqlite,model-postgres,model-mysql";
225    fail(message, message, cli.json, EXIT_USAGE, out, err)
226}
227
228#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
229fn execute(cli: &Cli, migrations: &str, seeds: &str, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
230    let (command, url) = (cli.command.clone(), cli.database_url.clone().or_else(|| std::env::var("DATABASE_URL").ok().filter(|u| !u.trim().is_empty())));
231    let (migrations, seeds) = (migrations.to_string(), seeds.to_string());
232    let result = crate::dev_mcp::db::run(async move {
233        let pool = match &url {
234            Some(url) => DbPool::connect(url).await?,
235            None => DbPool::from_env().await.map_err(|e| {
236                crate::model::DbError::new(format!("{} (or give --database-url / DATABASE_URL)", e.0))
237            })?,
238        };
239        let answer = answer(&pool, &command, &migrations, &seeds).await;
240        close(&pool).await;
241        answer
242    });
243    match result {
244        Ok(json) => {
245            if cli.json {
246                let _ = writeln!(out, "{json}");
247            } else {
248                let _ = write!(out, "{}", render(&cli.command, &json));
249            }
250            EXIT_OK
251        }
252        Err(message) => fail(&message, &message, cli.json, EXIT_FAILED, out, err),
253    }
254}
255
256#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
257async fn close(pool: &DbPool) {
258    match pool {
259        #[cfg(feature = "model-sqlite")]
260        DbPool::Sqlite(p) => p.close().await,
261        #[cfg(feature = "model-postgres")]
262        DbPool::Postgres(p) => p.close().await,
263        #[cfg(feature = "model-mysql")]
264        DbPool::MySql(p) => p.close().await,
265    }
266}
267
268/// The command's result as JSON: `status` is the dev MCP server's
269/// `migrations` answer; `migrate` adds `"applied"` (the versions this run
270/// applied) to it; `rollback` is `{"rolled_back":[…]}`, `seed`
271/// `{"directory":…,"seeded":[…]}`.
272#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
273async fn answer(pool: &DbPool, command: &Command, migrations: &str, seeds: &str) -> Result<String, crate::model::DbError> {
274    let list = |items: &[String]| format!("[{}]", items.iter().map(|v| quoted(v)).collect::<Vec<_>>().join(","));
275    match command {
276        Command::Status => crate::dev_mcp::db::status_json(pool, migrations).await,
277        Command::Migrate => {
278            let pending: Vec<String> =
279                pool.migration_status(migrations).await?.into_iter().filter(|m| !m.applied).map(|m| m.version).collect();
280            pool.migrate(migrations).await?;
281            let status = crate::dev_mcp::db::status_json(pool, migrations).await?;
282            // {"directory":…,"migrations":[…]} → {"applied":[…],"directory":…,…}
283            Ok(format!(r#"{{"applied":{},{}"#, list(&pending), &status[1..]))
284        }
285        Command::Rollback(n) => {
286            let undone = pool.rollback(migrations, *n).await?;
287            Ok(format!(r#"{{"directory":{},"rolled_back":{}}}"#, quoted(migrations), list(&undone)))
288        }
289        Command::Seed => {
290            let ran = pool.seed(seeds).await?;
291            Ok(format!(r#"{{"directory":{},"seeded":{}}}"#, quoted(seeds), list(&ran)))
292        }
293    }
294}
295
296/// Text for a person, from the JSON [`answer`] built.
297#[cfg_attr(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")), allow(dead_code))]
298fn render(command: &Command, json: &str) -> String {
299    use crate::mcp::json_rpc::{extract_raw, extract_str, split_array_elements};
300    let elements = |key: &str| extract_raw(json, key).map(|raw| split_array_elements(raw.trim())).unwrap_or_default();
301    let strings = |key: &str| -> Vec<String> {
302        elements(key)
303            .iter()
304            .map(|v| v.trim().trim_matches('"').replace("\\\"", "\"").replace("\\\\", "\\"))
305            .collect()
306    };
307    let lines = |items: Vec<String>, none: &str, prefix: &str| -> String {
308        if items.is_empty() {
309            format!("{none}\n")
310        } else {
311            items.iter().map(|v| format!("{prefix}{v}\n")).collect()
312        }
313    };
314    match command {
315        Command::Migrate => lines(strings("applied"), "nothing to apply: every migration is applied", "applied  "),
316        Command::Rollback(_) => lines(strings("rolled_back"), "nothing to roll back: no migration is applied", "rolled back  "),
317        Command::Seed => lines(strings("seeded"), "no seed files", "seeded  "),
318        Command::Status => {
319            let migrations = elements("migrations");
320            if migrations.is_empty() {
321                return "no migrations\n".to_string();
322            }
323            migrations
324                .iter()
325                .map(|m| {
326                    let version = extract_str(m, "version").unwrap_or_default();
327                    let applied = m.contains(r#""applied":true"#);
328                    let down = if m.contains(r#""has_down":true"#) { "" } else { "  (no .down.sql)" };
329                    format!("{}  {version}{down}\n", if applied { "applied" } else { "pending" })
330                })
331                .collect()
332        }
333    }
334}