Skip to main content

rws_proxy/db_cli/
mod.rs

1//! `rws db migrate|rollback|status|seed|schema` (18.30.0, #91): the
2//! project's migrations and seeds from the command line — the same
3//! operations, and for `status` / `schema` the same JSON, as the dev MCP
4//! server's `migrations`, `migrations_run`, `migrations_rollback_last` and
5//! `db_schema` tools (they share `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`. An app's own binary can
18//!   offer the same command with seeders written in Rust after the SQL
19//!   files: [`run_with_seeders`].
20//! - **Schema**: `schema` prints every table's columns, primary key,
21//!   indexes and foreign keys (`DbPool::schema`); `--json` as the dev MCP
22//!   server's `db_schema` answers it.
23//!
24//! The `rws` binary has the command in every build, but it only reaches a
25//! database when built with a `model-*` feature (`cargo install
26//! rust-web-server --features model-sqlite,model-postgres,model-mysql`);
27//! otherwise it says so and exits with [`EXIT_USAGE`].
28
29use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use crate::app_manifest::AppManifest;
33#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
34use crate::model::DbPool;
35
36#[cfg(test)]
37mod tests;
38
39/// Done.
40pub const EXIT_OK: i32 = 0;
41/// The database or a migration failed (connection, SQL error, missing
42/// `.down.sql`, unreadable directory): see the message.
43pub const EXIT_FAILED: i32 = 1;
44/// Bad arguments, or a build without a database driver.
45pub const EXIT_USAGE: i32 = 2;
46
47const USAGE: &str = "\
48Usage: rws db <command> [--database-url <url>] [--dir <project>]
49                        [--migrations <dir>] [--seeds <dir>] [--json]
50
51Commands:
52  migrate        apply every pending migration, each in its own transaction
53  rollback [N]   undo the last N applied migrations (default 1) with their .down.sql files
54  status         list the migrations in order: applied or pending, and whether each has a .down.sql
55  seed           run every *.sql seed file, in name order, each in its own transaction
56  schema         the tables: columns, primary key, indexes, foreign keys (--json: as data)
57
58Database: --database-url (sqlite:app.db, postgres://user:pass@host/db,
59mysql://user:pass@host/db), else DATABASE_URL, else RWS_DB_BACKEND, RWS_DB_NAME,
60RWS_DB_HOST, RWS_DB_PORT, RWS_DB_USER, RWS_DB_PASSWORD.
61Migrations: the `migrations` path of rws.app.toml under --dir (migrations/ by
62default). Seeds: seeds/ beside the migrations directory (db/migrations ->
63db/seeds). Seeds run again every time: write them so they can.
64--json prints the result (or {\"error\": …}) for agents.
65Exit codes: 0 done, 1 database or migration error, 2 usage.
66";
67
68/// `rws db …` asked for: the arguments after `db`.
69pub fn requested(args: &[String]) -> Option<&[String]> {
70    (args.get(1).map(String::as_str) == Some("db")).then(|| &args[2..])
71}
72
73/// What to do.
74#[derive(Debug, Clone, PartialEq, Eq)]
75enum Command {
76    Migrate,
77    Rollback(usize),
78    Status,
79    Seed,
80    Schema,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84struct Cli {
85    command: Command,
86    database_url: Option<String>,
87    dir: PathBuf,
88    migrations: Option<PathBuf>,
89    seeds: Option<PathBuf>,
90    json: bool,
91}
92
93enum Parsed {
94    Help,
95    Run(Cli),
96}
97
98/// Run `rws db <args>` (the arguments after `db`), writing to `out` and
99/// `err`; answers the exit code ([`EXIT_OK`], [`EXIT_FAILED`],
100/// [`EXIT_USAGE`]).
101pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
102    run_seeding(args, Seeding::default(), out, err)
103}
104
105/// As [`run`], with `seeders` run after the SQL seed files by `seed`
106/// (18.30.0) — for an app's own binary, so `myapp db seed` runs the seeds
107/// written in Rust too (the `rws` binary can't link them):
108///
109/// ```rust,ignore
110/// let args: Vec<String> = std::env::args().collect();
111/// if let Some(db_args) = rust_web_server::db_cli::requested(&args) {
112///     let code = rust_web_server::db_cli::run_with_seeders(db_args, &myapp::seeders(), &mut std::io::stdout(), &mut std::io::stderr());
113///     std::process::exit(code);
114/// }
115/// ```
116///
117/// With seeders, a missing seeds directory means no SQL seeds rather than
118/// an error (`DbPool::seed_with`).
119#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
120pub fn run_with_seeders(args: &[String], seeders: &crate::model::Seeders, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
121    run_seeding(args, Seeding { seeders: Some(seeders.clone()) }, out, err)
122}
123
124/// The Rust seeders `seed` runs, if any.
125#[derive(Default)]
126struct Seeding {
127    #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
128    seeders: Option<crate::model::Seeders>,
129}
130
131fn run_seeding(args: &[String], seeding: Seeding, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
132    let json = args.iter().any(|a| a == "--json");
133    let cli = match parse(args) {
134        Ok(Parsed::Help) => {
135            let _ = write!(out, "{USAGE}");
136            return EXIT_OK;
137        }
138        Ok(Parsed::Run(cli)) => cli,
139        Err(message) => return fail(&format!("{message}\n(rws db --help lists the commands)"), &message, json, EXIT_USAGE, out, err),
140    };
141    let (migrations, seeds) = match directories(&cli) {
142        Ok(dirs) => dirs,
143        Err(message) => return fail(&message, &message, json, EXIT_FAILED, out, err),
144    };
145    execute(&cli, &migrations, &seeds, seeding, out, err)
146}
147
148fn fail(text: &str, message: &str, json: bool, code: i32, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
149    if json {
150        let _ = writeln!(out, r#"{{"error":{}}}"#, quoted(message));
151    } else {
152        let _ = writeln!(err, "rws db: {text}");
153    }
154    code
155}
156
157fn quoted(s: &str) -> String {
158    format!("\"{}\"", crate::core::json::escape(s))
159}
160
161fn parse(args: &[String]) -> Result<Parsed, String> {
162    let mut positional: Vec<String> = Vec::new();
163    let mut cli = Cli { command: Command::Status, database_url: None, dir: PathBuf::from("."), migrations: None, seeds: None, json: false };
164    let mut i = 0;
165    while i < args.len() {
166        let arg = args[i].as_str();
167        i += 1;
168        if arg == "-h" || arg == "--help" || (arg == "help" && positional.is_empty()) {
169            return Ok(Parsed::Help);
170        }
171        if !arg.starts_with("--") {
172            positional.push(arg.to_string());
173            continue;
174        }
175        let (flag, inline) = match arg.split_once('=') {
176            Some((f, v)) => (f, Some(v.to_string())),
177            None => (arg, None),
178        };
179        let mut value = |name: &str| -> Result<String, String> {
180            if let Some(v) = inline.clone() {
181                return Ok(v);
182            }
183            let v = args.get(i).cloned().ok_or_else(|| format!("{name} needs a value"))?;
184            i += 1;
185            Ok(v)
186        };
187        match flag {
188            "--json" => cli.json = true,
189            "--database-url" => cli.database_url = Some(value("--database-url")?),
190            "--dir" => cli.dir = PathBuf::from(value("--dir")?),
191            "--migrations" => cli.migrations = Some(PathBuf::from(value("--migrations")?)),
192            "--seeds" => cli.seeds = Some(PathBuf::from(value("--seeds")?)),
193            other => return Err(format!("unknown option {other}")),
194        }
195    }
196    let Some(command) = positional.first() else {
197        return Err("name a command: migrate, rollback, status, seed or schema".to_string());
198    };
199    let extra = |at: usize| positional.get(at).map(|extra| format!("rws db {command} takes no `{extra}`"));
200    cli.command = match command.as_str() {
201        "migrate" | "status" | "seed" | "schema" => {
202            if let Some(e) = extra(1) {
203                return Err(e);
204            }
205            match command.as_str() {
206                "migrate" => Command::Migrate,
207                "status" => Command::Status,
208                "schema" => Command::Schema,
209                _ => Command::Seed,
210            }
211        }
212        "rollback" => {
213            if let Some(e) = extra(2) {
214                return Err(e);
215            }
216            let n = match positional.get(1) {
217                None => 1,
218                Some(n) => match n.parse::<usize>() {
219                    Ok(n) if n > 0 => n,
220                    _ => return Err(format!("rws db rollback {n}: give how many migrations to undo, a number from 1")),
221                },
222            };
223            Command::Rollback(n)
224        }
225        other => return Err(format!("unknown command `{other}`: use migrate, rollback, status, seed or schema")),
226    };
227    Ok(Parsed::Run(cli))
228}
229
230/// The migrations and seeds directories for `cli`.
231fn directories(cli: &Cli) -> Result<(String, String), String> {
232    let migrations = match &cli.migrations {
233        Some(dir) => dir.clone(),
234        None => {
235            let manifest = AppManifest::load(&cli.dir).map_err(|e| e.to_string())?;
236            cli.dir.join(&manifest.paths.migrations)
237        }
238    };
239    let seeds = match &cli.seeds {
240        Some(dir) => dir.clone(),
241        None => seeds_beside(&migrations),
242    };
243    Ok((path_text(&migrations), path_text(&seeds)))
244}
245
246/// `seeds/` in the directory that holds `migrations`.
247fn seeds_beside(migrations: &Path) -> PathBuf {
248    let trimmed: PathBuf = migrations.components().collect();
249    match trimmed.parent() {
250        Some(parent) if !parent.as_os_str().is_empty() => parent.join("seeds"),
251        _ => PathBuf::from("seeds"),
252    }
253}
254
255fn path_text(path: &Path) -> String {
256    path.to_string_lossy().into_owned()
257}
258
259#[cfg(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
260fn execute(cli: &Cli, _migrations: &str, _seeds: &str, _seeding: Seeding, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
261    let message = "this rws was built without a database driver — install one with them: \
262                   cargo install rust-web-server --features model-sqlite,model-postgres,model-mysql";
263    fail(message, message, cli.json, EXIT_USAGE, out, err)
264}
265
266#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
267fn execute(cli: &Cli, migrations: &str, seeds: &str, seeding: Seeding, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
268    let (command, url) = (cli.command.clone(), cli.database_url.clone().or_else(|| std::env::var("DATABASE_URL").ok().filter(|u| !u.trim().is_empty())));
269    let (migrations, seeds) = (migrations.to_string(), seeds.to_string());
270    let result = crate::dev_mcp::db::run(async move {
271        let pool = match &url {
272            Some(url) => DbPool::connect(url).await?,
273            None => DbPool::from_env().await.map_err(|e| {
274                crate::model::DbError::new(format!("{} (or give --database-url / DATABASE_URL)", e.0))
275            })?,
276        };
277        let answer = answer(&pool, &command, &migrations, &seeds, seeding.seeders.as_ref()).await;
278        close(&pool).await;
279        answer
280    });
281    match result {
282        Ok(json) => {
283            if cli.json {
284                let _ = writeln!(out, "{json}");
285            } else {
286                let _ = write!(out, "{}", render(&cli.command, &json));
287            }
288            EXIT_OK
289        }
290        Err(message) => fail(&message, &message, cli.json, EXIT_FAILED, out, err),
291    }
292}
293
294#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
295async fn close(pool: &DbPool) {
296    pool.close().await;
297}
298
299/// The command's result as JSON: `status` is the dev MCP server's
300/// `migrations` answer; `migrate` adds `"applied"` (the versions this run
301/// applied) to it; `rollback` is `{"rolled_back":[…]}`, `seed`
302/// `{"directory":…,"seeded":[…]}` (SQL files, then Rust seeders), `schema`
303/// the dev MCP server's `db_schema` answer.
304#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
305async fn answer(
306    pool: &DbPool,
307    command: &Command,
308    migrations: &str,
309    seeds: &str,
310    seeders: Option<&crate::model::Seeders>,
311) -> Result<String, crate::model::DbError> {
312    let list = |items: &[String]| format!("[{}]", items.iter().map(|v| quoted(v)).collect::<Vec<_>>().join(","));
313    match command {
314        Command::Status => crate::dev_mcp::db::status_json(pool, migrations).await,
315        Command::Migrate => {
316            let pending: Vec<String> =
317                pool.migration_status(migrations).await?.into_iter().filter(|m| !m.applied).map(|m| m.version).collect();
318            pool.migrate(migrations).await?;
319            let status = crate::dev_mcp::db::status_json(pool, migrations).await?;
320            // {"directory":…,"migrations":[…]} → {"applied":[…],"directory":…,…}
321            Ok(format!(r#"{{"applied":{},{}"#, list(&pending), &status[1..]))
322        }
323        Command::Rollback(n) => {
324            let undone = pool.rollback(migrations, *n).await?;
325            Ok(format!(r#"{{"directory":{},"rolled_back":{}}}"#, quoted(migrations), list(&undone)))
326        }
327        Command::Seed => {
328            let ran = match seeders {
329                Some(seeders) => pool.seed_with(seeds, seeders).await?,
330                None => pool.seed(seeds).await?,
331            };
332            Ok(format!(r#"{{"directory":{},"seeded":{}}}"#, quoted(seeds), list(&ran)))
333        }
334        Command::Schema => crate::dev_mcp::db::schema_json(pool).await,
335    }
336}
337
338/// Text for a person, from the JSON [`answer`] built.
339#[cfg_attr(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")), allow(dead_code))]
340fn render(command: &Command, json: &str) -> String {
341    use crate::mcp::json_rpc::{extract_raw, extract_str, split_array_elements};
342    let elements = |key: &str| extract_raw(json, key).map(|raw| split_array_elements(raw.trim())).unwrap_or_default();
343    let strings = |key: &str| -> Vec<String> {
344        elements(key)
345            .iter()
346            .map(|v| v.trim().trim_matches('"').replace("\\\"", "\"").replace("\\\\", "\\"))
347            .collect()
348    };
349    let lines = |items: Vec<String>, none: &str, prefix: &str| -> String {
350        if items.is_empty() {
351            format!("{none}\n")
352        } else {
353            items.iter().map(|v| format!("{prefix}{v}\n")).collect()
354        }
355    };
356    match command {
357        Command::Migrate => lines(strings("applied"), "nothing to apply: every migration is applied", "applied  "),
358        Command::Rollback(_) => lines(strings("rolled_back"), "nothing to roll back: no migration is applied", "rolled back  "),
359        Command::Seed => lines(strings("seeded"), "no seed files", "seeded  "),
360        Command::Schema => render_schema(json),
361        Command::Status => {
362            let migrations = elements("migrations");
363            if migrations.is_empty() {
364                return "no migrations\n".to_string();
365            }
366            migrations
367                .iter()
368                .map(|m| {
369                    let version = extract_str(m, "version").unwrap_or_default();
370                    let applied = m.contains(r#""applied":true"#);
371                    let down = if m.contains(r#""has_down":true"#) { "" } else { "  (no .down.sql)" };
372                    format!("{}  {version}{down}\n", if applied { "applied" } else { "pending" })
373                })
374                .collect()
375        }
376    }
377}
378
379/// `schema` for a person: each table, its columns (type, `NOT NULL`,
380/// default, `PK`), indexes and foreign keys.
381#[cfg_attr(not(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")), allow(dead_code))]
382fn render_schema(json: &str) -> String {
383    use crate::mcp::json_rpc::{extract_raw, extract_str, split_array_elements};
384    let elements = |json: &str, key: &str| extract_raw(json, key).map(|raw| split_array_elements(raw.trim())).unwrap_or_default();
385    let names = |json: &str, key: &str| -> String {
386        elements(json, key).iter().map(|v| v.trim().trim_matches('"').to_string()).collect::<Vec<_>>().join(", ")
387    };
388    let tables = elements(json, "tables");
389    if tables.is_empty() {
390        return "no tables\n".to_string();
391    }
392    let mut out = String::new();
393    for table in tables {
394        out.push_str(&format!("{}\n", extract_str(&table, "name").unwrap_or_default()));
395        for column in elements(&table, "columns") {
396            let mut line = format!("  {}  {}", extract_str(&column, "name").unwrap_or_default(), extract_str(&column, "type").unwrap_or_default());
397            if column.contains(r#""nullable":false"#) {
398                line.push_str("  NOT NULL");
399            }
400            if let Some(default) = extract_str(&column, "default") {
401                line.push_str(&format!("  DEFAULT {default}"));
402            }
403            if column.contains(r#""primary_key":true"#) {
404                line.push_str("  PK");
405            }
406            out.push_str(&line);
407            out.push('\n');
408        }
409        for index in elements(&table, "indexes") {
410            let kind = if index.contains(r#""primary":true"#) {
411                "primary key"
412            } else if index.contains(r#""unique":true"#) {
413                "unique index"
414            } else {
415                "index"
416            };
417            out.push_str(&format!("  {kind} {} ({})\n", extract_str(&index, "name").unwrap_or_default(), names(&index, "columns")));
418        }
419        for key in elements(&table, "foreign_keys") {
420            let references = extract_raw(&key, "references").unwrap_or_default();
421            out.push_str(&format!(
422                "  foreign key ({}) references {} ({}) on delete {}\n",
423                names(&key, "columns"),
424                extract_str(&references, "table").unwrap_or_default(),
425                names(&references, "columns"),
426                extract_str(&key, "on_delete").unwrap_or_default()
427            ));
428        }
429    }
430    out
431}