From 541a1407e0bb9c194181d4aabb017d5cf157bce6 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 17 Jun 2026 19:35:17 +0200 Subject: [PATCH] fix: include postgres error detail in migration run/rollback failures --- .../windmill-api-workspaces/src/workspaces.rs | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index dc3abfccf9..b89639260c 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -151,6 +151,10 @@ pub fn workspaced_service() -> Router { "/delete_datatable_migration/{datatable_name}/{timestamp}", delete(delete_datatable_migration), ) + .route( + "/upsert_datatable_migration/{datatable_name}", + post(upsert_datatable_migration), + ) .route( "/generate_initial_datatable_migration/{datatable_name}", post(generate_initial_datatable_migration), @@ -2512,9 +2516,15 @@ async fn run_datatable_migrations( .await .map_err(|e| Error::internal_err(format!("Failed to start transaction: {}", e)))?; tx.batch_execute(&m.code_up).await.map_err(|e| { + // Surface the underlying Postgres message (e.g. "syntax error at ...") + // rather than tokio_postgres's terse "db error". + let detail = e + .as_db_error() + .map(|d| d.message().to_string()) + .unwrap_or_else(|| e.to_string()); Error::internal_err(format!( "Failed to apply migration {} ({}): {}", - m.timestamp, m.name, e + m.timestamp, m.name, detail )) })?; tx.execute( @@ -2651,9 +2661,13 @@ async fn rollback_datatable_migrations( .await .map_err(|e| Error::internal_err(format!("Failed to start transaction: {}", e)))?; tx.batch_execute(&code_down).await.map_err(|e| { + let detail = e + .as_db_error() + .map(|d| d.message().to_string()) + .unwrap_or_else(|| e.to_string()); Error::internal_err(format!( "Failed to roll back migration {} ({}): {}", - version, definition.name, e + version, definition.name, detail )) })?; tx.execute("DELETE FROM _wm_migrations WHERE version = $1", &[&version]) @@ -2976,6 +2990,58 @@ async fn delete_datatable_migration( )) } +#[derive(Deserialize)] +pub struct UpsertDatatableMigration { + pub timestamp: i64, + pub name: String, + pub code_up: String, + #[serde(default)] + pub code_down: Option, +} + +/// Insert or update a single migration at an explicit version. Used by +/// `wmill sync` to push a `datatable_migrations/
/_.up.sql` +/// (and `.down.sql`) file as the source of truth. +async fn upsert_datatable_migration( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(payload): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + sqlx::query!( + "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE \ + SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down", + &w_id, + &datatable_name, + payload.timestamp, + &payload.name, + &payload.code_up, + payload.code_down.as_deref(), + ) + .execute(&db) + .await?; + + audit_log( + &db, + &authed, + "workspaces.upsert_datatable_migration", + ActionKind::Update, + &w_id, + Some(datatable_name.as_str()), + None, + ) + .await?; + + Ok(format!( + "Upserted migration {} in {}", + payload.timestamp, datatable_name + )) +} + /// Generate the first migration for a data table by snapshotting its current /// schema with `pg_dump`. The migration is recorded as already installed (its /// version is written to `_wm_migrations` *before* the definition row, so it is