feat(migration): replace sqlx::migrate with standalone Go migrate tool

Schema is now managed by a separate Go CLI at tools/migrate/, which embeds
the SQL files copied from server/initialize/migrate/database/ and tracks
state in the shared schema_migrations table. Rust no longer owns migrations
at startup.

Rust startup:
  1. Connect to DB
  2. Probe for the `user` table (schema marker)
  3. If missing, fork-exec `ppanel-migrate up` to bring schema to v2131
  4. Create admin if not present
  5. Continue with redis/queue/scheduler

Verified:
  - Empty DB → ppanel-migrate applies all 39 migrations in ~0.4s, admin created
  - Go server's ppanel_go DB → schema detected, migrate skipped, clean start

Changes:
  - Add tools/migrate/ — Go module wrapping golang-migrate
    * migrate/migrate.go: Migrate() + RunUp() (RunUp bypasses the iofs vs
      os.ErrNotExist sentinel mismatch that breaks Up() on already-migrated DBs)
    * migrate/sql/{postgres,mysql}/: SQL files copied from server
    * cmd/migrate/main.go: CLI (up/down/version/force/drop)
  - Widen password column from varchar(100) → varchar(255) in
    00001_init_schema.up.sql to fit Rust's 112-char PBKDF2 hash
    (Rust uses 16-byte salt; Go's 8-byte salt fits in 100 but Rust's doesn't)
  - Rewrite src/migration.rs:
    * Remove sqlx::migrate!, run_migrations()
    * Add ensure_schema(db, cfg): probe + invoke ppanel-migrate subprocess
  - Fix src/db.rs::build_dsn: previously used cfg.config verbatim even when
    the default config string was for the wrong dialect (PG got MySQL's
    charset=utf8mb4 string and crashed on connect)
  - Delete old migrations/{postgres,mysql}/ (no longer used)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ember Moth
2026-07-06 02:11:10 +08:00
parent 0305d058c4
commit e4996ade03
166 changed files with 556 additions and 123 deletions
+41 -10
View File
@@ -39,23 +39,20 @@ pub async fn init_pool(cfg: &DatabaseConfig) -> Result<Db, sqlx::Error> {
// ─── DSN builder ─────────────────────────────────────────────────────────
fn build_dsn(cfg: &DatabaseConfig) -> String {
pub(crate) fn build_dsn(cfg: &DatabaseConfig) -> String {
let dialect = detect_dialect(cfg);
let addr = cfg
.addr
.as_deref()
.filter(|a| !a.is_empty())
.unwrap_or(match detect_dialect(cfg) {
.unwrap_or(match dialect {
Dialect::Postgres => "localhost:5432",
Dialect::Mysql => "localhost:3306",
});
let password = url_encode_password(&cfg.password);
let query = if cfg.config.is_empty() {
default_query(cfg)
} else {
&cfg.config
};
let query = pick_query(cfg, dialect);
match detect_dialect(cfg) {
match dialect {
Dialect::Postgres => format!(
"postgres://{}:{}@{}/{}?{}",
cfg.username, password, addr, cfg.dbname, query,
@@ -71,8 +68,42 @@ fn detect_dialect(cfg: &DatabaseConfig) -> Dialect {
Dialect::from_driver(&cfg.driver)
}
fn default_query(cfg: &DatabaseConfig) -> &'static str {
match detect_dialect(cfg) {
/// Pick the URL query string for the DSN.
///
/// `DatabaseConfig.config` defaults to a MySQL-flavoured string
/// (`charset=utf8mb4&parseTime=true&loc=...`), so an unset YAML field
/// silently gives the wrong params to a Postgres connection. To stay
/// backwards-compatible we only honour `cfg.config` when it looks
/// compatible with the active dialect; otherwise fall back to
/// `default_query`.
fn pick_query(cfg: &DatabaseConfig, dialect: Dialect) -> String {
let raw = cfg.config.trim();
if !raw.is_empty() && query_matches_dialect(raw, dialect) {
return raw.to_string();
}
default_query(dialect).to_string()
}
fn query_matches_dialect(query: &str, dialect: Dialect) -> bool {
// Heuristic — MySQL uses `charset=` / `parseTime=` / `loc=`; Postgres
// uses `sslmode=` / `TimeZone=` / `channel_binding=`. Treat any MySQL-
// specific key under a Postgres dialect as a mismatch, and vice versa.
let q = query.to_ascii_lowercase();
match dialect {
Dialect::Postgres => {
!q.contains("charset=")
&& !q.contains("parsetime=")
&& !q.contains("&loc=")
&& !q.starts_with("loc=")
}
Dialect::Mysql => {
!(q.contains("sslmode=") || q.contains("timezone=") || q.contains("channel_binding="))
}
}
}
fn default_query(dialect: Dialect) -> &'static str {
match dialect {
Dialect::Postgres => "sslmode=disable&TimeZone=Asia/Shanghai",
Dialect::Mysql => "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
}