feat(migration)

This commit is contained in:
Ember Moth
2026-07-07 16:33:17 +08:00
parent 953a9e5409
commit aa71d56154
172 changed files with 966 additions and 103 deletions
+81
View File
@@ -0,0 +1,81 @@
//! Cross-language interface definition for the migration tool.
//!
//! This file is the **single source of truth** for the Rust↔Go boundary.
//! rust2go reads it and auto-generates:
//! - `gen.go` (Go-side interface + CGO exports) at
//! `tools/migrate/migrate/gen.go`
//! - The C binding file (`_go_bindings.rs` by default) used by the
//! Rust crate to call into the Go static library.
//!
//! The hand-written Go side lives in `tools/migrate/migrate/impl.go`.
pub mod binding {
#![allow(warnings)]
rust2go::r2g_include_binding!();
}
// ═══════════════════════════════════════════════════════════════════════════
// Wire types — must be `#[derive(R2G, Clone)]` and use only path-local
// primitive types. See the rust2go skill notes for the supported type
// matrix.
// ═══════════════════════════════════════════════════════════════════════════
/// Configuration for a single migration invocation. Maps 1:1 to the
/// `-driver` + `-dsn` flags on the standalone CLI.
#[derive(rust2go::R2G, Clone)]
pub struct MigrateConfig {
/// Either `"postgres"` or `"mysql"`. Any other value causes the Go
/// side to return an error in `MigrateOutcome.error`.
pub driver: String,
/// Full DSN understood by golang-migrate. The Go side will
/// auto-prepend `postgres://` or `mysql://` if missing, mirroring
/// the CLI behaviour.
pub dsn: String,
}
/// Result of a migration call. Errors are signalled by populating
/// `error` with a non-empty message — we deliberately use a plain
/// `String` (not a sum type) so the type marshalling stays trivial and
/// panic-free across the FFI boundary.
#[derive(rust2go::R2G, Clone)]
pub struct MigrateOutcome {
/// Final schema version after the call. `0` if the
/// `schema_migrations` table is empty (fresh database).
pub version: u32,
/// Whether golang-migrate flagged the migration as dirty. A dirty
/// state means a previous migration failed mid-way; the caller
/// should refuse to serve traffic and request human intervention.
pub dirty: bool,
/// Empty string on success. Populated with the Go-side error
/// message on failure. Rust converts this to a panic with the
/// same semantics as the previous subprocess call.
pub error: String,
}
// ═══════════════════════════════════════════════════════════════════════════
// Trait — the cross-language service definition.
// ═══════════════════════════════════════════════════════════════════════════
/// Schema migration service exposed from Go to Rust.
///
/// Each method is a **synchronous** call: Rust blocks the calling
/// thread until Go returns. This is intentional — these are called
/// once at startup before any traffic is served, so we don't need
/// async / shm / drop-safe machinery (which would add complexity for
/// no runtime benefit). The Go side is a single goroutine per call,
/// gated by a mutex on the `init` registration.
#[rust2go::r2g]
pub trait MigrateService {
/// Apply all pending migrations. Equivalent to `ppanel-migrate up`.
/// Idempotent: returns success with `version = latest` if the
/// database is already at the latest source version (matches the
/// `ErrNoChange` semantics of the standalone CLI).
fn up(cfg: MigrateConfig) -> MigrateOutcome;
/// Read the current `schema_migrations.version` and `dirty` flag
/// without applying anything. Useful for ops/debug.
fn version(cfg: MigrateConfig) -> MigrateOutcome;
}
+92
View File
@@ -0,0 +1,92 @@
//! Rust binding to the Go migration tool (`tools/migrate/migrate`).
//!
//! This crate wraps the Go-based schema migrator behind a Rust API
//! that can be called directly from the main binary — no subprocess,
//! no out-of-band `ppanel-migrate` binary, no `PPANEL_MIGRATE_BIN`
//! resolution.
//!
//! # Architecture
//!
//! ```text
//! ┌────────────────────────┐ FFI ┌──────────────────────┐
//! │ ppanel-backend (Rust) │ ───────────────► │ Go staticlib │
//! │ - main.rs │ │ - golang-migrate │
//! │ - migration.rs │ │ - SQL files │
//! │ │ │ (embedded via │
//! │ │ │ //go:embed) │
//! └────────────────────────┘ └──────────────────────┘
//! ```
//!
//! The Go side opens its own short-lived database connection per
//! call. The connection is closed when the call returns. We do not
//! share sqlx pools across the FFI boundary — that would require
//! additional plumbing for no real benefit (the migrator only runs
//! at startup).
//!
//! # Build
//!
//! The build is driven by `build.rs`, which calls
//! `rust2go::Builder`. The Builder compiles the Go code as CGO and
//! produces `_go_bindings.rs` containing the C declarations used by
//! the Rust side. The Go glue (`gen.go`) is regenerated automatically
//! whenever `src/idl.rs` changes.
//!
//! # Runtime
//!
//! The user must set the env vars documented by rust2go when the
//! binary is launched:
//!
//! ```text
//! GODEBUG=invalidptr=0,cgocheck=0
//! ```
//!
//! Without these, Go's conservative GC pointer checks will flag the
//! FFI references as invalid and abort. This is a property of
//! rust2go, not specific to this crate.
pub mod idl;
pub use idl::{MigrateConfig, MigrateOutcome, MigrateService, MigrateServiceImpl};
/// Apply all pending schema migrations.
///
/// Mirrors the behaviour of the previous `ppanel-migrate up`
/// subprocess call:
/// - **Idempotent**: if the database is already at the latest
/// version, returns silently with the current version.
/// - **Fail-fast**: on any Go-side error, panics with the Go error
/// message. The migrator is called at startup before any traffic
/// is served, so failing the process is the correct behaviour.
///
/// # Arguments
/// - `driver`: `"postgres"` or `"mysql"`.
/// - `dsn`: connection string in golang-migrate URL form. The Go
/// side will auto-prepend the URL scheme if missing.
pub fn up(driver: &str, dsn: &str) -> MigrateOutcome {
let cfg = MigrateConfig {
driver: driver.to_string(),
dsn: dsn.to_string(),
};
let outcome = MigrateServiceImpl::up(cfg);
if !outcome.error.is_empty() {
panic!(
"ppanel-migrate up failed: {}\n\
Build via: `cd tools/migrate && go build -o ppanel-migrate ./cmd/migrate`\n\
(or rely on the rust2go FFI path embedded at build time).",
outcome.error
);
}
outcome
}
/// Read the current schema version without applying anything.
///
/// Returns `MigrateOutcome { version: 0, dirty: false, error: "" }`
/// for a fresh database (no `schema_migrations` rows).
pub fn version(driver: &str, dsn: &str) -> MigrateOutcome {
let cfg = MigrateConfig {
driver: driver.to_string(),
dsn: dsn.to_string(),
};
MigrateServiceImpl::version(cfg)
}