Skip to content

Commit 92f828a

Browse files
committed
refactor(config)!: drop MySQL legacy compatibility paths
Remove the `[mysql]` section alias and `MYSQL_CONNECTION_URL` env var fallback so `[database]` / `DATABASE_URL` are the only accepted inputs. Prune the corresponding legacy tests, simplify the `parse_config` test helper whose `section_name` parameter only existed for the legacy alias test, and rewrite CLAUDE.md / README.md to state the current behavior directly instead of hedging for pre-upgrade deployments. BREAKING CHANGE: configs using `[mysql]` or env using `MYSQL_CONNECTION_URL` will no longer be accepted; migrate to `[database]` and `DATABASE_URL`. Signed-off-by: JmPotato <github@ipotato.me>
1 parent ee1484f commit 92f828a

File tree

3 files changed

+14
-86
lines changed

3 files changed

+14
-86
lines changed

CLAUDE.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project
66

7-
rsomhaP is a monolithic, server-rendered blog engine written in Rust. It is a rewrite of the Python project [Pomash](https://github.com/JmPotato/Pomash) and ships as a single binary backed by either a MySQL- or PostgreSQL-compatible database. Split DB config under `[database]` selects the backend via `backend = "mysql"` / `"postgres"`, while full `connection_url` / `DATABASE_URL` values still select the driver through their URL scheme. Legacy `[mysql]` config and `MYSQL_CONNECTION_URL` env input are still accepted as compatibility fallbacks, but new work should use `[database]` and `DATABASE_URL`.
7+
rsomhaP is a monolithic, server-rendered blog engine written in Rust. It is a rewrite of the Python project [Pomash](https://github.com/JmPotato/Pomash) and ships as a single binary backed by either a MySQL- or PostgreSQL-compatible database. Split DB config under `[database]` selects the backend via `backend = "mysql"` / `"postgres"`, while full `connection_url` / `DATABASE_URL` values select the driver through their URL scheme.
88

99
## Commands
1010

@@ -25,7 +25,7 @@ cargo test <test_name> # run a single test by name
2525

2626
# Docker
2727
docker build -t rsomhap .
28-
docker compose up # reads DATABASE_URL / MYSQL_CONNECTION_URL / UMAMI_ID from the environment
28+
docker compose up # reads DATABASE_URL / UMAMI_ID from the environment
2929
```
3030

3131
**All DB-backed tests live in `src/models/*` and must run serially.** They share real tables (`articles`, `tags`, `pages`, `users`) and truncate data between cases, so parallel execution corrupts state. Every DB test starts with `let Some(pool) = get_test_pool().await else { return; };` (where `get_test_pool` returns `Option<DbPool>`), so a plain `cargo test` without `TEST_DATABASE_URL` is a compile + pure-logic check only — it is not a substitute for running with a real DB before merging schema or model changes. The DB tests are **backend-agnostic**: they run against whichever backend `TEST_DATABASE_URL` points at. Run them against both MySQL and PostgreSQL before merging any change that touches `src/models/*`. Tests outside `src/models/*` (`is_safe_redirect`, `build_message_url`, `sort_out_tags`, `User` serde) are pure.
@@ -56,7 +56,7 @@ Sessions use `tower_sessions::MemoryStore` with a per-process `Key::generate()`
5656
### AppState and caches
5757
`AppState` (in `src/app.rs`) is cloned into an `Arc` and serves both as Axum state and as the `axum_login::AuthnBackend`. It holds:
5858

59-
- `config: Config` — parsed once from `config.toml`, with env var overrides (`DATABASE_URL`, legacy `MYSQL_CONNECTION_URL`, `PLAUSIBLE_DOMAIN`, `UMAMI_ID`) layered on top in `Config::load_env_vars`. `DATABASE_URL` takes precedence when both DB env vars are present. When split DB fields are used, `[database].backend` chooses whether `Config::database_url()` builds a MySQL or PostgreSQL DSN.
59+
- `config: Config` — parsed once from `config.toml`, with env var overrides (`DATABASE_URL`, `PLAUSIBLE_DOMAIN`, `UMAMI_ID`) layered on top in `Config::load_env_vars`. When split DB fields are used, `[database].backend` chooses whether `Config::database_url()` builds a MySQL or PostgreSQL DSN; `DATABASE_URL` (or an in-file `connection_url`) overrides that and its own URL scheme picks the driver.
6060
- `env: minijinja::Environment` — all templates loaded eagerly at startup from `templates/` via `add_template_owned`. **Template edits require a full restart**; the `minijinja` `loader` feature is enabled in `Cargo.toml` but is not wired up for hot reload.
6161
- `db: models::DbPool` — an enum wrapping either `sqlx::MySqlPool` or `sqlx::PgPool`. Backend is picked at `DbPool::connect` time from the URL scheme.
6262
- `feed_cache` + `page_titles_cache` — both `Arc<RwLock<...>>`.
@@ -81,13 +81,13 @@ Schema DDL is split across two files, one per backend, with no incremental migra
8181
- `src/models/postgres_schema.rs` — Postgres-flavored `CREATE TABLE IF NOT EXISTS` (with `SERIAL`, `TIMESTAMPTZ NOT NULL DEFAULT NOW()`, separate `CREATE INDEX IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS`). Postgres has **no** `ON UPDATE CURRENT_TIMESTAMP` equivalent — the target schema is identical in *columns*, but time bookkeeping diverges: see below.
8282
- `models::init_schema(&DbPool)` dispatches into the right one and issues the DDL through a transaction handle. Both files are idempotent via `IF NOT EXISTS`, so it is safe to call on every boot. Do not treat that as a cross-backend atomic migration guarantee: MySQL can implicitly commit DDL, so partial progress is still possible if a later statement fails.
8383

84-
**There is no schema-version table, no `run_migrations`, no error-code-catching.** The previous incremental migration pattern (catching MySQL error codes 1060/1061) is gone.
84+
**There is no schema-version table, no `run_migrations`, no error-code-catching.** `init_schema` is `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` only — it will **never** `ALTER` an existing table, add a missing column, or drop a stale one.
8585

8686
**Schema evolution workflow**:
8787
1. Edit both schema files (`src/models/mysql_schema.rs` and `src/models/postgres_schema.rs`) so they still describe the same target shape.
8888
2. Update every affected query in `articles.rs`, `pages.rs`, and `users.rs` for both backends.
8989
3. Add or update a test that proves repeated `init_schema` calls are still safe and do not destroy existing rows.
90-
4. Decide explicitly whether old deployed databases matter. Because this repo has no incremental migrations, changing the schema files only changes the target schema for fresh databases or manually migrated ones; an already-deployed database will not automatically gain a new column just because `init_schema` still runs on boot.
90+
4. Treat schema edits as applying only to fresh databases. `init_schema` will not `ALTER` an existing table into the new shape; any database that already exists has to be migrated out-of-band (or dropped and recreated) before the new code will run against it.
9191

9292
**`updated_at` bookkeeping is application-level**, not relied-upon at the DB level. Every `UPDATE` in the model files explicitly `SET updated_at = NOW()`, including `Page::update` and `User::modify_password`. MySQL's `ON UPDATE CURRENT_TIMESTAMP` column default remains as belt-and-suspenders but is load-bearing on **no** backend — do not rely on it. Postgres has no trigger; **any new `UPDATE` you add must bump `updated_at` explicitly or it will silently go stale on Postgres.**
9393

@@ -101,7 +101,7 @@ Schema DDL is split across two files, one per backend, with no incremental migra
101101

102102
**Article tags are dual-stored**, and both copies must stay in sync:
103103
- `articles.tags` holds a comma-separated canonical string (used for display and for round-tripping through the editor).
104-
- The `tags` table holds normalized rows keyed by `article_id` (used by `GET /tag/{name}` via `INNER JOIN` and by `Tags::get_all_with_count`). The target schema no longer defines `created_at`/`updated_at` on `tags`; they were dead weight. Old databases that predate this cleanup may still physically have those columns until manually migrated or recreated.
104+
- The `tags` table holds normalized rows keyed by `article_id` (used by `GET /tag/{name}` via `INNER JOIN` and by `Tags::get_all_with_count`). It intentionally carries no `created_at` / `updated_at` columns — a pure association table does not need them, and both backends' schemas reflect that.
105105

106106
`Article::insert` writes both; `Article::update` clears the `tags` rows via `clear_tags_{mysql,postgres}` and re-inserts them; `Article::delete` clears the `tags` rows inside the same transaction as the article delete. Any change to the CSV format (delimiter, normalization, case handling) must be applied atomically across both `Tags::insert_tags_{mysql,postgres}` helpers, `utils::sort_out_tags`, and `handler_article`'s display-time split on `,`.
107107

@@ -121,6 +121,6 @@ Not every field of `Config` is reachable from templates. `Config`, `Giscus`, `An
121121

122122
## Deployment
123123

124-
CI deploys to Fly.io on every push to `main` (`.github/workflows/fly-deploy.yml`). The workflow stages `DATABASE_URL` and `UMAMI_ID` as Fly secrets, then runs `fly deploy --remote-only`. The GitHub Actions secret must therefore be configured under the preferred `DATABASE_URL` name before deploys. The Fly app is configured in `fly.toml` (region `nrt`, internal port `5299`, `/ping` healthcheck, `min_machines_running = 0` with auto-suspend). Because the session store is in-process, suspensions and redeploys both log admin sessions out — acceptable for a single-admin blog, but a blocker for any future multi-tenant mode or multi-machine rollout.
124+
CI deploys to Fly.io on every push to `main` (`.github/workflows/fly-deploy.yml`). The workflow stages `DATABASE_URL` and `UMAMI_ID` as Fly secrets, then runs `fly deploy --remote-only`. The matching GitHub Actions secret must be configured under the `DATABASE_URL` name before deploys; the workflow has no fallback. The Fly app is configured in `fly.toml` (region `nrt`, internal port `5299`, `/ping` healthcheck, `min_machines_running = 0` with auto-suspend). Because the session store is in-process, suspensions and redeploys both log admin sessions out — acceptable for a single-admin blog, but a blocker for any future multi-tenant mode or multi-machine rollout.
125125

126-
**Database backend selection.** Split config-file fields (`username` / `password` / `host` / `port` / `database` under `[database]`) rely on `[database].backend = "mysql" | "postgres"` to decide which DSN scheme to build. If `connection_url` in the config or `DATABASE_URL` in the environment is present, that full URL wins and its own scheme still chooses the backend: `mysql://...` uses sqlx-mysql, `postgres://...` (or `postgresql://...`) uses sqlx-postgres. Legacy `[mysql]` and `MYSQL_CONNECTION_URL` are still accepted during upgrades, but treat them as compatibility paths rather than the steady-state interface.
126+
**Database backend selection.** Split config-file fields (`username` / `password` / `host` / `port` / `database` under `[database]`) rely on `[database].backend = "mysql" | "postgres"` to decide which DSN scheme to build. If `connection_url` in the config or `DATABASE_URL` in the environment is present, that full URL wins and its own scheme chooses the backend: `mysql://...` uses sqlx-mysql, `postgres://...` (or `postgresql://...`) uses sqlx-postgres.

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,7 @@ If you provide `connection_url` in the config or `DATABASE_URL` in the environme
3232
- `mysql://...` for MySQL
3333
- `postgres://...` / `postgresql://...` for PostgreSQL
3434

35-
Upgrade note:
36-
37-
- `DATABASE_URL` is the preferred environment variable.
38-
- Legacy `MYSQL_CONNECTION_URL` is still accepted as a compatibility fallback.
39-
- Legacy `[mysql]` config still parses, but `[database]` is the preferred section name going forward.
40-
- The default sample config binds to `0.0.0.0` so Docker and public deployments work out of the box. If you want local-only access, change `[deploy].host` to `127.0.0.1`.
35+
The default sample config binds to `0.0.0.0` so Docker and public deployments work out of the box. If you want local-only access, change `[deploy].host` to `127.0.0.1`.
4136

4237
```sh
4338
cargo run --release

src/config.rs

Lines changed: 5 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ pub struct Config {
164164
meta: Meta,
165165
admin: Admin,
166166
style: Style,
167-
#[serde(alias = "mysql")]
168167
database: Database,
169168
giscus: Giscus,
170169
analytics: Analytics,
@@ -186,8 +185,6 @@ impl Config {
186185
fn load_env_vars(&mut self) -> Result<(), Error> {
187186
if let Ok(database_url) = std::env::var("DATABASE_URL") {
188187
self.database.connection_url = Some(database_url);
189-
} else if let Ok(mysql_connection_url) = std::env::var("MYSQL_CONNECTION_URL") {
190-
self.database.connection_url = Some(mysql_connection_url);
191188
}
192189
if let Ok(plausible_domain) = std::env::var("PLAUSIBLE_DOMAIN") {
193190
self.analytics.plausible = Some(plausible_domain);
@@ -320,7 +317,7 @@ mod tests {
320317

321318
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
322319

323-
fn parse_config(section_name: &str, database_section: &str) -> Config {
320+
fn parse_config(database_section: &str) -> Config {
324321
toml::from_str(&format!(
325322
r#"
326323
[deploy]
@@ -339,7 +336,7 @@ username = "admin"
339336
article_per_page = 15
340337
code_syntax_highlight_theme = "base16-eighties.dark"
341338
342-
[{section_name}]
339+
[database]
343340
{database_section}
344341
345342
[giscus]
@@ -369,7 +366,6 @@ user_id = "@user"
369366
#[test]
370367
fn test_database_url_defaults_split_fields_to_mysql() {
371368
let config = parse_config(
372-
"database",
373369
r#"
374370
username = "root"
375371
password = "password"
@@ -389,7 +385,6 @@ database = "rsomhaP"
389385
#[test]
390386
fn test_database_url_uses_postgres_backend_for_split_fields() {
391387
let config = parse_config(
392-
"database",
393388
r#"
394389
backend = "postgres"
395390
username = "postgres"
@@ -410,7 +405,6 @@ database = "rsomhaP"
410405
#[test]
411406
fn test_database_url_prefers_connection_url_over_backend_field() {
412407
let config = parse_config(
413-
"database",
414408
r#"
415409
backend = "mysql"
416410
connection_url = "postgresql://postgres:secret@127.0.0.1:5432/rsomhaP"
@@ -430,78 +424,18 @@ database = "ignored"
430424
}
431425

432426
#[test]
433-
fn test_legacy_mysql_section_alias_still_parses() {
434-
let config = parse_config(
435-
"mysql",
436-
r#"
437-
username = "root"
438-
password = "password"
439-
host = "127.0.0.1"
440-
port = 4000
441-
database = "rsomhaP"
442-
"#,
443-
);
444-
445-
config.validate().unwrap();
446-
assert_eq!(
447-
config.database_url().unwrap(),
448-
"mysql://root:password@127.0.0.1:4000/rsomhaP"
449-
);
450-
}
451-
452-
#[test]
453-
fn test_load_env_vars_falls_back_to_legacy_mysql_connection_url() {
454-
let _guard = ENV_LOCK.lock().unwrap();
455-
let database_url_before = std::env::var_os("DATABASE_URL");
456-
let mysql_connection_url_before = std::env::var_os("MYSQL_CONNECTION_URL");
457-
458-
unsafe {
459-
std::env::remove_var("DATABASE_URL");
460-
std::env::set_var(
461-
"MYSQL_CONNECTION_URL",
462-
"mysql://legacy:password@127.0.0.1:4000/rsomhaP",
463-
);
464-
}
465-
466-
let mut config = parse_config(
467-
"database",
468-
r#"
469-
username = "root"
470-
password = "password"
471-
host = "127.0.0.1"
472-
port = 4000
473-
database = "rsomhaP"
474-
"#,
475-
);
476-
config.load_env_vars().unwrap();
477-
assert_eq!(
478-
config.database_url().unwrap(),
479-
"mysql://legacy:password@127.0.0.1:4000/rsomhaP"
480-
);
481-
482-
restore_env_var("DATABASE_URL", database_url_before);
483-
restore_env_var("MYSQL_CONNECTION_URL", mysql_connection_url_before);
484-
}
485-
486-
#[test]
487-
fn test_load_env_vars_prefers_database_url_over_legacy_mysql_connection_url() {
427+
fn test_load_env_vars_database_url_overrides_split_fields() {
488428
let _guard = ENV_LOCK.lock().unwrap();
489429
let database_url_before = std::env::var_os("DATABASE_URL");
490-
let mysql_connection_url_before = std::env::var_os("MYSQL_CONNECTION_URL");
491430

492431
unsafe {
493432
std::env::set_var(
494433
"DATABASE_URL",
495-
"postgres://preferred:secret@127.0.0.1:5432/rsomhaP",
496-
);
497-
std::env::set_var(
498-
"MYSQL_CONNECTION_URL",
499-
"mysql://legacy:password@127.0.0.1:4000/rsomhaP",
434+
"postgres://env:secret@127.0.0.1:5432/rsomhaP",
500435
);
501436
}
502437

503438
let mut config = parse_config(
504-
"database",
505439
r#"
506440
backend = "mysql"
507441
username = "root"
@@ -514,11 +448,10 @@ database = "rsomhaP"
514448
config.load_env_vars().unwrap();
515449
assert_eq!(
516450
config.database_url().unwrap(),
517-
"postgres://preferred:secret@127.0.0.1:5432/rsomhaP"
451+
"postgres://env:secret@127.0.0.1:5432/rsomhaP"
518452
);
519453

520454
restore_env_var("DATABASE_URL", database_url_before);
521-
restore_env_var("MYSQL_CONNECTION_URL", mysql_connection_url_before);
522455
}
523456

524457
fn restore_env_var(key: &str, previous: Option<std::ffi::OsString>) {

0 commit comments

Comments
 (0)