Files
windmill/backend/windmill-api-workspaces/src/datatable_migrations.rs
T
Ruben Fiszel bfc3f5242a feat: sync data table migrations to git, gated by a new object type (#10436)
* fix: auto-sync data table migrations to the linked git repo

* fix: deploy data table migrations when a data table is renamed or deleted

* fix: hold new data table names to the git-sync-safe charset

* fix: reject leading-dot data table names and warn on unsyncable legacy names

* chore: update ee-repo-ref to 15c9eef2a4f867eb90d841aee1ce762f4b725589

This commit updates the EE repository reference after PR #702 was merged in windmill-ee-private.

Previous ee-repo-ref: e2fb073a3d0057683666424e463b2ce423664caa

New ee-repo-ref: 15c9eef2a4f867eb90d841aee1ce762f4b725589

Automated by sync-ee-ref workflow.

* feat: make data table migrations a git-sync object type with its own toggle

* fix: never let an untracked checkout delete data table migrations on push

* fix: confirm ambiguous data table migration deletions instead of dropping them

* fix: settle ambiguous migration deletions before the dry-run preview prints

* fix: restore the split shared-UI comment and count migration records in prompts

* chore: keep the deletion-safety doc block attached to its function

* fix: trust git history, not the working tree, for migration deletions

* fix: scope migration history to HEAD, detect shallow clones and subdir roots

* chore: give the unattested-history case a remedy that applies to it

* chore: pair each unattested-history cause with its own remedy

* fix: treat a sparse checkout as unattested history for migration deletions

* fix: normalize the sparse-checkout boolean and give it a remedy that works

* chore: describe both shapes of unattested migration history

* chore: update ee-repo-ref to a786cd42b5aaf0aa6789fbb723d956560f93b1b3

This commit updates the EE repository reference after PR #703 was merged in windmill-ee-private.

Previous ee-repo-ref: 4f312642b5d8fd37ab5e20473a011d6f1d299cf6

New ee-repo-ref: a786cd42b5aaf0aa6789fbb723d956560f93b1b3

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-31 23:33:47 +02:00

1938 lines
67 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Data table SQL migrations: CRUD endpoints, run/rollback execution, opt-in
//! management, and the workspace-merge diff helper. Split out of `workspaces.rs`
//! to keep that file focused on core workspace configuration.
use crate::workspaces::{pg_dump_database, ItemComparison};
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
use std::collections::{HashMap, HashSet};
use tokio_postgres::error::SqlState;
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_api_jobs::run_wait_result_internal;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, DebouncingSettings};
use windmill_common::scripts::ScriptLang;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::worker::to_raw_value;
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
use windmill_common::{PgDatabase, DB};
use windmill_git_sync::{
handle_deployment_metadata, handle_deployment_metadata_batch, DeployedObject,
};
use windmill_queue::{push, PushArgs, PushIsolationLevel};
pub(crate) fn routes() -> Router {
Router::new()
.route(
"/run_datatable_migrations/{datatable_name}",
post(run_datatable_migrations),
)
.route(
"/rollback_datatable_migrations/{datatable_name}",
post(rollback_datatable_migrations),
)
.route("/list_datatable_migrations", get(list_datatable_migrations))
.route(
"/datatable_migrations_status/{datatable_name}",
get(datatable_migrations_status),
)
.route(
"/enable_datatable_migrations/{datatable_name}",
post(enable_datatable_migrations),
)
.route(
"/disable_datatable_migrations/{datatable_name}",
post(disable_datatable_migrations),
)
.route(
"/create_datatable_migration/{datatable_name}",
post(create_datatable_migration),
)
.route(
"/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),
)
}
#[derive(Serialize)]
struct AppliedMigration {
version: i64,
name: String,
}
#[derive(Serialize)]
struct RunDatatableMigrationsResult {
applied: Vec<AppliedMigration>,
}
#[derive(Deserialize)]
struct RunDatatableMigrationsQuery {
/// When set, only apply pending migrations up to and including this version.
up_to: Option<i64>,
/// When set, apply only this specific migration version (if not already
/// applied), ignoring any other pending migrations. Takes precedence over
/// `up_to`.
only: Option<i64>,
}
/// Build the `database` argument for a migration job. Both resource-backed and
/// instance data tables pass a `datatable://<name>` reference; the pg executor
/// resolves it to real credentials server-side at run time. It must never be
/// resolved here: the resolved instance credentials include a single
/// instance-wide Postgres password, and the job's `args` are readable by the —
/// possibly non-admin — user who ran the migration.
async fn datatable_database_arg(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<Box<serde_json::value::RawValue>> {
// Fail fast with a clear error if the data table doesn't exist.
sqlx::query_scalar!(
"SELECT ws.datatable->'datatables'->$2 FROM workspace_settings ws WHERE ws.workspace_id = $1",
w_id,
datatable_name,
)
.fetch_one(db)
.await?
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
}
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
/// the requesting user and labelled `datatable_migration` for traceability, then
/// wait for it. Errors if the job fails.
async fn run_datatable_migration_job(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
database_arg: &Box<serde_json::value::RawValue>,
sql: &str,
) -> Result<()> {
let mut args = HashMap::new();
args.insert("database".to_string(), database_arg.clone());
let push_args = PushArgs { extra: None, args: &args };
let (uuid, mut tx) = push(
db,
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
w_id,
JobPayload::Code(RawCode {
content: sql.to_string(),
path: Some("datatable_migration".to_string()),
hash: None,
language: ScriptLang::Postgresql,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
tag: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
modules: None,
}),
push_args,
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&authed.clone().into()),
false,
None,
None,
None,
)
.await?;
// Tag the job so migration runs are easy to find in the run history.
sqlx::query!(
"UPDATE v2_job SET labels = (
SELECT array_agg(DISTINCT l)
FROM unnest(coalesce(labels, ARRAY[]::TEXT[]) || $2) l
) WHERE id = $1",
uuid,
&vec!["datatable_migration".to_string()],
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let (result, success) =
run_wait_result_internal(db, uuid, w_id, None, false, &authed.username).await?;
if !success {
// On failure the job result is `{"error": {"name", "message", ...}}`;
// surface the executor's message (the Postgres error, e.g. `relation
// "foo" does not exist`) instead of the raw JSON envelope.
let detail = serde_json::from_str::<serde_json::Value>(result.get())
.ok()
.as_ref()
.and_then(|v| v.get("error"))
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(str::to_string)
.unwrap_or_else(|| result.get().to_string());
return Err(Error::internal_err(detail));
}
Ok(())
}
/// Ensure the `_wm_migrations` bookkeeping table exists. Migration versions are
/// only unique per data table, but several data-table configs can point at one
/// physical database, so it is keyed by `(datatable, version)` — a version-only
/// key would let one data table's migration mark another's same-version
/// migration as already applied (and rollback could touch the wrong row).
async fn ensure_wm_migrations_schema(client: &tokio_postgres::Client) -> Result<()> {
// `CREATE TABLE IF NOT EXISTS` checks CREATE on the schema before it checks
// existence, so probing first is what lets a data table whose role only holds
// DML grants keep migrating against an already-created bookkeeping table.
// `to_regclass` resolves through search_path, like the unqualified statements
// the rest of this module runs against it. Takes no parameters, so it goes
// through the simple protocol: a named prepared statement is what stalls
// behind a transaction-pooling proxy (see `pg_get_full_schema`).
let rows = client
.simple_query("SELECT to_regclass('_wm_migrations') IS NOT NULL AS present")
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to look up _wm_migrations table: {}",
pg_error_message(&e)
))
})?;
let exists = rows.iter().any(|msg| match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => row.get("present") == Some("t"),
_ => false,
});
if exists {
return Ok(());
}
let Err(e) = client
.batch_execute(
"CREATE TABLE IF NOT EXISTS _wm_migrations (\
datatable TEXT NOT NULL, \
version BIGINT NOT NULL, \
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), \
PRIMARY KEY (datatable, version))",
)
.await
else {
return Ok(());
};
let mut msg = format!(
"Failed to ensure _wm_migrations table: {}",
pg_error_message(&e)
);
// A role with only table-level grants cannot create it: since Postgres 15 the
// `public` schema no longer grants CREATE to PUBLIC, so this is the usual
// failure on a bring-your-own database.
if e.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE) {
// Windmill connects as the role that lacks the privilege, so it cannot
// grant it: hand over the statement a schema owner has to run instead.
// Keep it ahead of the explanation below — the UI collapses everything
// past the first couple of lines behind a "Show more".
if let Some((user, schema)) = connection_identity(client).await {
// Both come back unquoted, so a mixed-case or hyphenated name would
// otherwise render a statement that targets a different schema.
msg.push_str(&format!(
". Run: GRANT CREATE ON SCHEMA {} TO {}",
render_db_quoted_identifier(&schema, DbType::Postgresql),
render_db_quoted_identifier(&user, DbType::Postgresql),
));
}
msg.push_str(
". Applied migrations are recorded in a `_wm_migrations` table in the data \
table's own database, so its user needs to be able to create it",
);
}
Err(Error::internal_err(msg))
}
/// The role and default schema of a data table connection, for grant hints.
/// Both come from the server so the statement we suggest names what the
/// connection actually resolves to, not what the resource happens to say.
async fn connection_identity(client: &tokio_postgres::Client) -> Option<(String, String)> {
let rows = client
.simple_query("SELECT current_user AS usr, current_schema() AS sch")
.await
.ok()?;
rows.iter().find_map(|msg| match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => {
Some((row.get("usr")?.to_string(), row.get("sch")?.to_string()))
}
_ => None,
})
}
/// Open a connection to a data table's own database and hold the session-level
/// advisory lock that serializes migration runs/rollbacks. The lock is released
/// when the returned client is dropped, so callers must keep it in scope for the
/// whole critical section.
///
/// Runs, rollbacks *and* definition rewrites/deletes all take this lock: a run
/// snapshots a migration's `code_up` from `datatable_migrations` and only records
/// its version in `_wm_migrations` after the job succeeds, so an unserialized edit
/// could rewrite the definition in that window and leave `_wm_migrations` pointing
/// at SQL that was never applied. `_wm_migrations` is per-database, so a single
/// key is sufficient.
async fn lock_datatable_migration_runs(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<tokio_postgres::Client> {
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
client
.batch_execute("SELECT pg_advisory_lock(hashtext('windmill_datatable_migrations')::int8)")
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to acquire migration lock: {}",
pg_error_message(&e)
))
})?;
Ok(client)
}
/// Read the versions recorded as applied in a data table's `_wm_migrations`,
/// scoped to that data table, using an existing connection. An absent table
/// (`42P01`) means nothing has been migrated yet.
async fn read_applied_versions_on_client(
client: &tokio_postgres::Client,
datatable_name: &str,
) -> Result<HashSet<i64>> {
match client
.query(
"SELECT version FROM _wm_migrations WHERE datatable = $1",
&[&datatable_name],
)
.await
{
Ok(rows) => Ok(rows.iter().map(|row| row.get::<_, i64>(0)).collect()),
Err(e) if e.as_db_error().map(|d| d.code().code()) == Some("42P01") => Ok(HashSet::new()),
Err(e) => Err(Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))),
}
}
/// Apply the workspace's pending data table migrations to a given data table.
/// Each migration runs as a normal Windmill `postgresql` job (permissioned as
/// the requester, labelled `datatable_migration`); applied versions are then
/// recorded in the data table's own `_wm_migrations` table, so only migrations
/// not recorded there are run, in ascending `timestamp` order.
async fn run_datatable_migrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Query(query): Query<RunDatatableMigrationsQuery>,
) -> JsonResult<RunDatatableMigrationsResult> {
audit_log(
&db,
&authed,
"workspaces.run_datatable_migrations",
ActionKind::Update,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
// Take the run-serialization lock before snapshotting the migration
// definitions: a concurrent definition rewrite/delete takes the same lock, so
// the `code_up` we read here can't change between now and when we record its
// version below. The lock is held until `client` drops at return.
let client = lock_datatable_migration_runs(&db, &w_id, &datatable_name).await?;
let migrations = sqlx::query!(
"SELECT timestamp, name, code_up FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 ORDER BY timestamp ASC",
&w_id,
&datatable_name,
)
.fetch_all(&db)
.await?;
ensure_wm_migrations_schema(&client).await?;
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
let mut applied = Vec::new();
for m in migrations {
if let Some(only) = query.only {
// Run a single specific migration, skipping every other one.
if m.timestamp != only {
continue;
}
} else if query.up_to.is_some_and(|up_to| m.timestamp > up_to) {
// Migrations are ordered ascending, so once we pass `up_to` we're done.
break;
}
if applied_versions.contains(&m.timestamp) {
continue;
}
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to apply migration {} ({}): {}",
m.timestamp, m.name, e
))
})?;
// Record the migration as installed once its job has succeeded.
client
.execute(
"INSERT INTO _wm_migrations (datatable, version) VALUES ($1, $2) \
ON CONFLICT (datatable, version) DO NOTHING",
&[&datatable_name, &m.timestamp],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to record migration: {}",
pg_error_message(&e)
))
})?;
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
}
Ok(Json(RunDatatableMigrationsResult { applied }))
}
#[derive(Serialize)]
struct RolledBackMigration {
version: i64,
name: String,
}
#[derive(Serialize)]
struct RollbackDatatableMigrationsResult {
rolled_back: Vec<RolledBackMigration>,
}
#[derive(Deserialize)]
struct RollbackDatatableMigrationsQuery {
/// When set, roll back this specific applied migration version instead of
/// the most recently applied one.
only: Option<i64>,
}
/// Roll back a migration on a given data table: run its `code_down` as a normal
/// Windmill `postgresql` job (permissioned as the requester, labelled
/// `datatable_migration`) then drop its `_wm_migrations` row. Without `only` this
/// targets the most recently applied migration (one step); with `only` it
/// targets that specific applied version.
async fn rollback_datatable_migrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Query(query): Query<RollbackDatatableMigrationsQuery>,
) -> JsonResult<RollbackDatatableMigrationsResult> {
audit_log(
&db,
&authed,
"workspaces.rollback_datatable_migrations",
ActionKind::Update,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
// The data table's `_wm_migrations` bookkeeping is read here and the version
// dropped after the job succeeds; the down SQL itself runs in the job. The
// lock is held (until `client` drops at return) so a concurrent run or
// definition rewrite can't interleave with this rollback.
let client = lock_datatable_migration_runs(&db, &w_id, &datatable_name).await?;
ensure_wm_migrations_schema(&client).await?;
// Resolve which applied version to roll back: a specific one when `only` is
// given (and actually applied), otherwise the most recently applied. Scoped
// to this data table so a shared physical database can't surface another
// data table's version.
let target = match query.only {
Some(only) => client
.query_opt(
"SELECT version FROM _wm_migrations WHERE datatable = $1 AND version = $2",
&[&datatable_name, &only],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))
})?,
None => client
.query_opt(
"SELECT version FROM _wm_migrations WHERE datatable = $1 \
ORDER BY version DESC LIMIT 1",
&[&datatable_name],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))
})?,
};
let version: i64 = match target {
Some(row) => row.get::<_, i64>(0),
None => {
return Ok(Json(RollbackDatatableMigrationsResult {
rolled_back: vec![],
}))
}
};
let definition = sqlx::query!(
"SELECT name, code_down FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
&w_id,
&datatable_name,
version
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::BadRequest(format!(
"Cannot roll back migration {version}: its definition no longer exists"
))
})?;
let code_down = definition.code_down.ok_or_else(|| {
Error::BadRequest(format!(
"Cannot roll back migration {} ({}): it has no down migration",
version, definition.name
))
})?;
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to roll back migration {} ({}): {}",
version, definition.name, e
))
})?;
// Forget the version once its down job has succeeded.
client
.execute(
"DELETE FROM _wm_migrations WHERE datatable = $1 AND version = $2",
&[&datatable_name, &version],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to drop migration record: {}",
pg_error_message(&e)
))
})?;
Ok(Json(RollbackDatatableMigrationsResult {
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
}))
}
#[derive(Serialize, Deserialize)]
pub struct DatatableMigration {
pub datatable: String,
pub timestamp: i64,
pub name: String,
pub code_up: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_down: Option<String>,
}
async fn list_datatable_migrations(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DatatableMigration>> {
let migrations = sqlx::query_as!(
DatatableMigration,
"SELECT datatable, timestamp, name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 ORDER BY datatable, timestamp ASC",
&w_id
)
.fetch_all(&db)
.await?;
Ok(Json(migrations))
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum DatatableMigrationRunStatus {
/// Recorded in the data table's `_wm_migrations` table.
Ran,
/// Defined but not yet applied.
NotRun,
/// Applied status could not be determined (connection failure).
Unknown,
}
#[derive(Serialize)]
struct DatatableMigrationWithStatus {
timestamp: i64,
name: String,
code_up: String,
#[serde(skip_serializing_if = "Option::is_none")]
code_down: Option<String>,
status: DatatableMigrationRunStatus,
}
#[derive(Serialize)]
struct DatatableMigrationsStatusResult {
/// Whether the migrations feature is opted in for this data table.
enabled: bool,
migrations: Vec<DatatableMigrationWithStatus>,
/// Set when the applied status couldn't be read from the data table.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// Whether the SQL-migrations feature is enabled for a data table. Honors the
/// explicit `migrations_enabled` flag; when unset (data tables predating the
/// feature) it is considered enabled only if migrations already exist.
async fn datatable_migrations_enabled(db: &DB, w_id: &str, datatable_name: &str) -> Result<bool> {
let flag: Option<bool> = sqlx::query_scalar!(
"SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \
FROM workspace_settings ws WHERE ws.workspace_id = $1",
w_id,
datatable_name,
)
.fetch_optional(db)
.await?
.flatten();
match flag {
Some(v) => Ok(v),
None => Ok(sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2)",
w_id,
datatable_name,
)
.fetch_one(db)
.await?
.unwrap_or(false)),
}
}
/// Reject the request when migrations are not enabled for the data table.
async fn ensure_datatable_migrations_enabled(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<()> {
if !datatable_migrations_enabled(db, w_id, datatable_name).await? {
return Err(Error::BadRequest(format!(
"Migrations are not enabled for data table '{}'. Enable them first.",
datatable_name
)));
}
Ok(())
}
/// Read the versions recorded in a data table's `_wm_migrations` table. A
/// missing table means nothing has been applied yet (empty set, not an error).
async fn read_applied_datatable_versions(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<HashSet<i64>> {
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
// Read-only status path: don't create the table here, and don't take the run
// lock — a stale-by-a-moment applied set is fine for display.
read_applied_versions_on_client(&client, datatable_name).await
}
/// List a data table's migrations annotated with whether each has been applied.
async fn datatable_migrations_status(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatableMigrationsStatusResult> {
let enabled = datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
if !enabled {
return Ok(Json(DatatableMigrationsStatusResult {
enabled: false,
migrations: vec![],
error: None,
}));
}
let defs = sqlx::query!(
"SELECT timestamp, name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 ORDER BY timestamp ASC",
&w_id,
&datatable_name,
)
.fetch_all(&db)
.await?;
let (applied, error) = match read_applied_datatable_versions(&db, &w_id, &datatable_name).await
{
Ok(set) => (Some(set), None),
Err(e) => (None, Some(e.to_string())),
};
let migrations = defs
.into_iter()
.map(|m| {
let status = match &applied {
Some(set) if set.contains(&m.timestamp) => DatatableMigrationRunStatus::Ran,
Some(_) => DatatableMigrationRunStatus::NotRun,
None => DatatableMigrationRunStatus::Unknown,
};
DatatableMigrationWithStatus {
timestamp: m.timestamp,
name: m.name,
code_up: m.code_up,
code_down: m.code_down,
status,
}
})
.collect();
Ok(Json(DatatableMigrationsStatusResult {
enabled: true,
migrations,
error,
}))
}
/// Only workspace admins and super admins may opt a data table in or out of
/// migrations.
async fn require_datatable_migrations_manager(db: &DB, authed: &ApiAuthed) -> Result<()> {
if authed.is_admin || require_super_admin(db, &authed.email).await.is_ok() {
Ok(())
} else {
Err(Error::BadRequest(
"Only workspace admins and super admins can enable or disable data table migrations"
.to_string(),
))
}
}
async fn enable_datatable_migrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> Result<String> {
require_datatable_migrations_manager(&db, &authed).await?;
let updated = sqlx::query_scalar!(
"UPDATE workspace_settings \
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2::text, 'migrations_enabled'], 'true'::jsonb) \
WHERE workspace_id = $1 AND jsonb_exists(datatable->'datatables', $2) \
RETURNING 1",
&w_id,
&datatable_name,
)
.fetch_optional(&db)
.await?;
if updated.is_none() {
return Err(Error::NotFound(format!(
"data table {datatable_name} not found"
)));
}
audit_log(
&db,
&authed,
"workspaces.enable_datatable_migrations",
ActionKind::Update,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
Ok(format!(
"Enabled migrations for data table {datatable_name}"
))
}
/// Opt a data table out of migrations. Deletes ALL of its migration definitions.
async fn disable_datatable_migrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> Result<String> {
require_datatable_migrations_manager(&db, &authed).await?;
let mut tx = db.begin().await?;
let updated = sqlx::query_scalar!(
"UPDATE workspace_settings \
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2::text, 'migrations_enabled'], 'false'::jsonb) \
WHERE workspace_id = $1 AND jsonb_exists(datatable->'datatables', $2) \
RETURNING 1",
&w_id,
&datatable_name,
)
.fetch_optional(&mut *tx)
.await?;
if updated.is_none() {
return Err(Error::NotFound(format!(
"data table {datatable_name} not found"
)));
}
// Capture the deleted definitions so each removal is tallied as a deployed
// object (like single-migration deletion), keeping workspace comparison and
// git-sync callbacks in sync when a fork opts back out of migrations.
let deleted = sqlx::query!(
"DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 \
RETURNING timestamp, name",
&w_id,
&datatable_name,
)
.fetch_all(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.disable_datatable_migrations",
ActionKind::Delete,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
tx.commit().await?;
for m in deleted {
record_datatable_migration_deployment(
&authed,
&db,
&w_id,
&datatable_name,
m.timestamp,
&m.name,
)
.await?;
}
Ok(format!(
"Disabled migrations for data table {datatable_name} and deleted its migrations"
))
}
#[derive(Deserialize)]
pub struct CreateDatatableMigration {
pub name: String,
pub code_up: String,
#[serde(default)]
pub code_down: Option<String>,
}
/// Migration names map onto on-disk file names and the `_wm_migrations` record,
/// so keep them to a safe path-segment charset (matches the CLI scaffold).
fn validate_migration_name(name: &str) -> Result<()> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(Error::BadRequest(format!(
"Invalid migration name '{name}': use only letters, digits, '_' and '-'"
)));
}
Ok(())
}
/// The data table name becomes a directory segment in the sync export
/// (`migrations/datatable/<datatable>/...`); reject anything that could escape it.
pub(crate) fn validate_datatable_path_segment(datatable: &str) -> Result<()> {
if datatable.is_empty()
|| datatable.contains('/')
|| datatable.contains('\\')
|| datatable.contains("..")
{
return Err(Error::BadRequest(format!(
"Invalid data table name '{datatable}': must not contain '/', '\\' or '..'"
)));
}
Ok(())
}
/// A data table's name is a path segment of every migration it owns, and git sync
/// carries that path through three matchers that all speak the same restricted
/// charset: `transform_regexp` expands a repo's `**` filter to `[a-zA-Z0-9_\-./]*`,
/// the CLI reuses the path as a minimatch pattern (where a leading `.` also needs
/// `dot: true`, which the CLI does not set), and the deploy stages it as a
/// `git add` pathspec. A name outside the charset matches nothing in all three, so
/// its migrations would silently never reach the repo.
///
/// Only names being introduced are held to this — an existing data table keeps
/// saving (and can be renamed to a syncable name) instead of locking its workspace
/// out of the settings form.
pub(crate) fn validate_new_datatable_name(datatable: &str) -> Result<()> {
validate_datatable_path_segment(datatable)?;
let starts_alphanumeric = datatable
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
if !starts_alphanumeric
|| !datatable
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
return Err(Error::BadRequest(format!(
"Invalid data table name '{datatable}': start with a letter or digit and use only \
letters, digits, '_', '-' and '.' so its SQL migrations can be synced to a git \
repository"
)));
}
Ok(())
}
/// Record a data table migration change as a deployed object so it is tallied
/// into `workspace_diff` and shows up as a `datatable_migration` item in the
/// workspace-merge diff. The diff path is `<datatable>/<timestamp>_<name>`,
/// matching `parse_datatable_migration_diff_path`.
async fn record_datatable_migration_deployment(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
datatable: &str,
timestamp: i64,
name: &str,
) -> Result<()> {
// A data table predating `validate_new_datatable_name` can carry a name no
// repo filter can match; say so once per change instead of letting the deploy
// vanish inside the path filter.
if validate_new_datatable_name(datatable).is_err() {
tracing::warn!(
"Data table '{datatable}' has a name git sync cannot match, so its SQL migrations \
will not reach a linked repository. Rename it to letters, digits, '_', '-' and '.'."
);
}
handle_deployment_metadata(
&authed.email,
&authed.username,
db,
w_id,
DeployedObject::DatatableMigration { path: format!("{datatable}/{timestamp}_{name}") },
Some(format!(
"Data table migration {name} ({timestamp}) on {datatable}"
)),
false,
None,
)
.await
}
/// Allocate the next version for a data table and insert the migration
/// definition, in one transaction. A per-(workspace, data table) advisory lock
/// serializes concurrent version allocation so two creates can't read the same
/// `MAX(timestamp)` and collide on the `(workspace_id, datatable, timestamp)`
/// primary key. The version is the current UTC `YYYYMMDDHHMMSS`, bumped past any
/// existing version to stay unique and monotonically increasing.
async fn insert_datatable_migration_def(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
datatable: &str,
name: &str,
code_up: &str,
code_down: Option<&str>,
) -> Result<i64> {
sqlx::query!(
"SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))",
w_id,
datatable,
)
.execute(&mut **tx)
.await?;
let now_ts: i64 = Utc::now()
.format("%Y%m%d%H%M%S")
.to_string()
.parse()
.map_err(|e| Error::internal_err(format!("Failed to build migration version: {}", e)))?;
let max_existing: Option<i64> = sqlx::query_scalar!(
"SELECT MAX(timestamp) FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2",
w_id,
datatable,
)
.fetch_one(&mut **tx)
.await?;
let timestamp = match max_existing {
Some(m) if m >= now_ts => m + 1,
_ => now_ts,
};
sqlx::query!(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \
VALUES ($1, $2, $3, $4, $5, $6)",
w_id,
datatable,
timestamp,
name,
code_up,
code_down,
)
.execute(&mut **tx)
.await?;
Ok(timestamp)
}
/// Mark a version as already installed in a data table's `_wm_migrations` table
/// (ensuring the table exists first).
async fn mark_datatable_version_installed(
db: &DB,
pg_db: &PgDatabase,
datatable: &str,
version: i64,
) -> Result<()> {
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
ensure_wm_migrations_schema(&client).await?;
client
.execute(
"INSERT INTO _wm_migrations (datatable, version) VALUES ($1, $2) \
ON CONFLICT (datatable, version) DO NOTHING",
&[&datatable, &version],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to mark initial migration installed: {}",
pg_error_message(&e)
))
})?;
Ok(())
}
/// Create a single migration for a data table. The version is generated
/// server-side (current UTC `YYYYMMDDHHMMSS`), bumped past any existing version
/// so it stays unique and monotonically increasing.
async fn create_datatable_migration(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Json(payload): Json<CreateDatatableMigration>,
) -> JsonResult<DatatableMigration> {
validate_datatable_path_segment(&datatable_name)?;
validate_migration_name(&payload.name)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
let mut tx = db.begin().await?;
let timestamp = insert_datatable_migration_def(
&mut tx,
&w_id,
&datatable_name,
&payload.name,
&payload.code_up,
payload.code_down.as_deref(),
)
.await?;
tx.commit().await?;
audit_log(
&db,
&authed,
"workspaces.create_datatable_migration",
ActionKind::Create,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
record_datatable_migration_deployment(
&authed,
&db,
&w_id,
&datatable_name,
timestamp,
&payload.name,
)
.await?;
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
name: payload.name,
code_up: payload.code_up,
code_down: payload.code_down,
}))
}
/// Delete a single migration definition from a data table.
async fn delete_datatable_migration(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>,
) -> Result<String> {
// Hold the run-serialization lock across the applied-check and the delete: a
// run snapshots a migration's SQL before recording its version, so an
// unserialized delete could race it and leave `_wm_migrations` pointing at a
// definition that no longer exists (breaking rollback and hiding the applied
// version). Held until the handler returns. Fail closed if we can't verify.
let unreachable = |e| {
Error::internal_err(format!(
"Cannot verify whether migration {} on data table '{}' has already been applied \
(its database is unreachable: {}). Refusing to delete it; retry once the database \
is reachable.",
timestamp, datatable_name, e
))
};
let lock_client = lock_datatable_migration_runs(&db, &w_id, &datatable_name)
.await
.map_err(unreachable)?;
let applied = read_applied_versions_on_client(&lock_client, &datatable_name)
.await
.map_err(unreachable)?;
if applied.contains(&timestamp) {
return Err(Error::BadRequest(format!(
"Migration {} on data table '{}' has already been applied and cannot be deleted. \
Revert it first.",
timestamp, datatable_name
)));
}
let deleted_name = sqlx::query_scalar!(
"DELETE FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 \
RETURNING name",
&w_id,
&datatable_name,
timestamp,
)
.fetch_optional(&db)
.await?;
// The definition is removed; runs may resume (audit/deploy metadata below
// don't need the lock).
drop(lock_client);
audit_log(
&db,
&authed,
"workspaces.delete_datatable_migration",
ActionKind::Delete,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
// Only tally a change if a migration was actually deleted.
if let Some(name) = deleted_name {
record_datatable_migration_deployment(
&authed,
&db,
&w_id,
&datatable_name,
timestamp,
&name,
)
.await?;
}
Ok(format!(
"Deleted migration {} from {}",
timestamp, datatable_name
))
}
#[derive(Deserialize)]
pub struct UpsertDatatableMigration {
pub timestamp: i64,
pub name: String,
pub code_up: String,
#[serde(default)]
pub code_down: Option<String>,
}
/// Insert or update a single migration at an explicit version. Used by
/// `wmill sync` to push a `migrations/datatable/<dt>/<version>_<name>.up.sql`
/// (and `.down.sql`) file as the source of truth.
async fn upsert_datatable_migration(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Json(payload): Json<UpsertDatatableMigration>,
) -> Result<String> {
validate_datatable_path_segment(&datatable_name)?;
validate_migration_name(&payload.name)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
// Guard against silently rewriting a migration that has already run in the
// data table's database: its `_wm_migrations` record would no longer match
// its SQL, so a later `migrate up` would skip it and a rollback would run a
// `down` that doesn't correspond to what was applied. Only an actual change
// to an existing migration is guarded; unchanged re-pushes (e.g.
// `wmill sync push`) always proceed.
let existing = sqlx::query!(
"SELECT name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
&w_id,
&datatable_name,
payload.timestamp,
)
.fetch_optional(&db)
.await?;
// When modifying an existing definition, hold the run-serialization lock
// across the applied-check and the write below so an in-flight run can't
// record a version for the SQL we're about to overwrite. Held until the end
// of the handler (well past the write); a new/unchanged upsert needs no lock.
let _run_lock = match existing {
Some(existing)
if !(existing.name == payload.name
&& existing.code_up == payload.code_up
&& existing.code_down == payload.code_down) =>
{
// Fail closed: if we can't lock/read the applied set (e.g. the
// data-table database is temporarily unreachable), refuse the change
// rather than risk overwriting a migration that has already run.
let unreachable = |e| {
Error::internal_err(format!(
"Cannot verify whether migration {} on data table '{}' has already been \
applied (its database is unreachable: {}). Refusing to modify it; retry \
once the database is reachable.",
payload.timestamp, datatable_name, e
))
};
let client = lock_datatable_migration_runs(&db, &w_id, &datatable_name)
.await
.map_err(unreachable)?;
let applied = read_applied_versions_on_client(&client, &datatable_name)
.await
.map_err(unreachable)?;
if applied.contains(&payload.timestamp) {
return Err(Error::BadRequest(format!(
"Migration {} on data table '{}' has already been applied and cannot be modified. \
Revert it first, or add a new migration instead.",
payload.timestamp, datatable_name
)));
}
Some(client)
}
_ => None,
};
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?;
// The definition is written; runs may resume (audit/deploy metadata below
// don't need the lock).
drop(_run_lock);
audit_log(
&db,
&authed,
"workspaces.upsert_datatable_migration",
ActionKind::Update,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
record_datatable_migration_deployment(
&authed,
&db,
&w_id,
&datatable_name,
payload.timestamp,
&payload.name,
)
.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 (the
/// definition is written first, then its version is marked in the data table's
/// `_wm_migrations`, so it ends up considered applied and is never re-run) and
/// has no down migration.
async fn generate_initial_datatable_migration(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatableMigration> {
validate_datatable_path_segment(&datatable_name)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
// The initial snapshot only makes sense on a data table with no migrations
// yet; reject otherwise so repeated calls don't pile up duplicate "initial"
// definitions (each at a distinct timestamp, each marked installed).
let has_migrations: bool = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2)",
&w_id,
&datatable_name,
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if has_migrations {
return Err(Error::BadRequest(format!(
"Data table '{datatable_name}' already has migrations; the initial migration can only be generated when there are none."
)));
}
let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
// Snapshot the schema, excluding Windmill's own migration bookkeeping table.
let dump_file = pg_dump_database(&pg_db, true, &["_wm_migrations"]).await?;
let raw_dump = tokio::fs::read_to_string(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to read schema dump: {}", e)))?;
// pg_dump emits psql meta-commands (\restrict / \unrestrict) that aren't
// valid SQL; drop them so the migration body can run via a plain query.
let code_up: String = raw_dump
.lines()
.filter(|line| !line.trim_start().starts_with('\\'))
.collect::<Vec<_>>()
.join("\n");
// Record the definition first, then mark it installed. If marking fails we
// delete the definition, so a failure leaves no phantom "initial" (rather
// than a `_wm_migrations` version with no definition that the UI can't
// clear). The narrow window where it briefly shows "not run" is benign:
// running it would just no-op/fail harmlessly against the existing schema.
let mut tx = db.begin().await?;
let timestamp =
insert_datatable_migration_def(&mut tx, &w_id, &datatable_name, "initial", &code_up, None)
.await?;
tx.commit().await?;
if let Err(e) = mark_datatable_version_installed(&db, &pg_db, &datatable_name, timestamp).await
{
let _ = sqlx::query!(
"DELETE FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
&w_id,
&datatable_name,
timestamp,
)
.execute(&db)
.await;
return Err(e);
}
audit_log(
&db,
&authed,
"workspaces.generate_initial_datatable_migration",
ActionKind::Create,
&w_id,
Some(datatable_name.as_str()),
None,
)
.await?;
record_datatable_migration_deployment(
&authed,
&db,
&w_id,
&datatable_name,
timestamp,
"initial",
)
.await?;
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
name: "initial".to_string(),
code_up,
code_down: None,
}))
}
/// A datatable migration diff item has path `<datatable>/<timestamp>_<name>`.
/// Parse out the (datatable, timestamp) needed to look it up.
pub(crate) fn parse_datatable_migration_diff_path(path: &str) -> Option<(String, i64)> {
let (datatable, file) = path.split_once('/')?;
let ts_str: String = file.chars().take_while(|c| c.is_ascii_digit()).collect();
let timestamp = ts_str.parse::<i64>().ok()?;
Some((datatable.to_string(), timestamp))
}
pub(crate) async fn compare_two_datatable_migration(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
let (datatable, timestamp) = match parse_datatable_migration_diff_path(path) {
Some(v) => v,
None => {
return Ok(ItemComparison {
has_changes: false,
exists_in_source: false,
exists_in_fork: false,
})
}
};
let source = sqlx::query!(
"SELECT name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
source_workspace_id,
datatable,
timestamp,
)
.fetch_optional(db)
.await?;
let target = sqlx::query!(
"SELECT name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
fork_workspace_id,
datatable,
timestamp,
)
.fetch_optional(db)
.await?;
let has_changes = match (&source, &target) {
(Some(s), Some(t)) => {
s.name != t.name || s.code_up != t.code_up || s.code_down != t.code_down
}
_ => source.is_some() || target.is_some(),
};
Ok(ItemComparison {
has_changes,
exists_in_source: source.is_some(),
exists_in_fork: target.is_some(),
})
}
#[derive(Deserialize, Debug)]
pub(crate) struct DatatableRename {
pub(crate) from: String,
pub(crate) to: String,
}
async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result<PgDatabase> {
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable).await?;
serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
}
/// Tolerate a data table whose database has no `_wm_migrations` yet (42P01 =
/// undefined_table): it has never run a migration, so nothing to rename or forget.
fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> {
match e.as_db_error().map(|d| d.code().code()) {
Some("42P01") => Ok(()),
_ => Err(Error::internal_err(format!(
"Failed to update _wm_migrations: {}",
pg_error_message(&e)
))),
}
}
/// Drop a data table's rows from its own database's `_wm_migrations`.
async fn remote_forget_datatable_migrations(db: &DB, w_id: &str, datatable: &str) -> Result<()> {
let pg_db = resolve_datatable_pg(db, w_id, datatable).await?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
let _ = connection.await;
});
client
.execute(
"DELETE FROM _wm_migrations WHERE datatable = $1",
&[&datatable],
)
.await
.map(|_| ())
.or_else(ignore_missing_wm_migrations)
}
/// Relabel a data table's rows in its own database's `_wm_migrations`. `resolve_by`
/// names the config entry used to find the database (the old name, still present
/// pre-commit); `from`/`to` are the `datatable` column values to move between.
async fn remote_rename_datatable_migrations(
db: &DB,
w_id: &str,
resolve_by: &str,
from: &str,
to: &str,
) -> Result<()> {
let pg_db = resolve_datatable_pg(db, w_id, resolve_by).await?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
let _ = connection.await;
});
client
.execute(
"UPDATE _wm_migrations SET datatable = $2 WHERE datatable = $1",
&[&from, &to],
)
.await
.map(|_| ())
.or_else(ignore_missing_wm_migrations)
}
/// Keep migration bookkeeping in sync when data tables are renamed or deleted in
/// the workspace config: the control table `datatable_migrations` (this database,
/// in `tx`) and each data table's own `_wm_migrations` (its own database, keyed
/// by data table name — see [`ensure_wm_migrations_schema`]).
///
/// Renames are applied in two phases through a temporary key so that a rename
/// chain or a swap (A->B, B->A) can't transiently collide on the
/// (datatable, ...) uniqueness mid-update.
///
/// The `_wm_migrations` updates are best-effort: they run just before `tx`
/// commits (resolved via the pool, which still exposes the old names), and a
/// temporarily unreachable data-table database is logged rather than failing the
/// whole config edit. If one is missed, the next run re-applies its migrations
/// against the existing schema.
///
/// Returns every migration path the cascade moved or removed, so the caller can
/// deploy them once `tx` commits — otherwise a linked git repo keeps the files of
/// a deleted or pre-rename data table forever.
pub(crate) async fn cascade_datatable_migration_renames_and_deletes(
db: &DB,
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
renames: &[DatatableRename],
deleted_datatables: &[String],
) -> Result<Vec<String>> {
let mut changed_paths: Vec<String> = vec![];
if !deleted_datatables.is_empty() {
let deleted = sqlx::query!(
"DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = ANY($2::text[]) \
RETURNING datatable, timestamp, name",
w_id,
deleted_datatables
)
.fetch_all(&mut **tx)
.await?;
changed_paths.extend(
deleted
.into_iter()
.map(|m| format!("{}/{}_{}", m.datatable, m.timestamp, m.name)),
);
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
w_id,
&r.from,
&tmp
)
.execute(&mut **tx)
.await?;
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
let moved = sqlx::query!(
"UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2 \
RETURNING timestamp, name",
w_id,
&tmp,
&r.to
)
.fetch_all(&mut **tx)
.await?;
for m in moved {
// Both ends: the old path so its files are dropped from the repo, the
// new one so they reappear under the renamed data table.
changed_paths.push(format!("{}/{}_{}", r.from, m.timestamp, m.name));
changed_paths.push(format!("{}/{}_{}", r.to, m.timestamp, m.name));
}
}
for name in deleted_datatables {
if let Err(e) = remote_forget_datatable_migrations(db, w_id, name).await {
tracing::warn!("Failed to clear _wm_migrations for deleted data table {name}: {e}");
}
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
if let Err(e) = remote_rename_datatable_migrations(db, w_id, &r.from, &r.from, &tmp).await {
tracing::warn!(
"Failed to stage _wm_migrations rename {} -> {}: {e}",
r.from,
r.to
);
}
}
for (i, r) in renames.iter().enumerate() {
let tmp = format!("__wm_rename_tmp/{i}");
if let Err(e) = remote_rename_datatable_migrations(db, w_id, &r.from, &tmp, &r.to).await {
tracing::warn!(
"Failed to finish _wm_migrations rename {} -> {}: {e}",
r.from,
r.to
);
}
}
Ok(changed_paths)
}
/// Deploy the migration paths a data table rename/delete moved or removed, so a
/// linked git repo drops the stale files and picks up the renamed ones. Call
/// after the config transaction commits — the deploy reads the workspace export.
pub(crate) async fn record_datatable_cascade_deployments(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
changed_paths: Vec<String>,
) -> Result<()> {
if changed_paths.is_empty() {
return Ok(());
}
let objs = changed_paths
.into_iter()
.map(|path| DeployedObject::DatatableMigration { path })
.collect();
handle_deployment_metadata_batch(
&authed.email,
&authed.username,
db,
w_id,
objs,
Some("Data table migrations updated by a data table rename or deletion".to_string()),
)
.await
}
/// Copy a workspace's migration definitions to another workspace, so a fork
/// inherits the same per-data-table migration history as its parent.
pub(crate) async fn clone_datatable_migrations(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down)
SELECT $2, datatable, timestamp, name, code_up, code_down
FROM datatable_migrations WHERE workspace_id = $1",
source_workspace_id,
target_workspace_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_migration_name_accepts_safe_names() {
for name in ["initial", "add_index_to_customers", "fix-bug_2", "ABC123"] {
assert!(
validate_migration_name(name).is_ok(),
"{name} should be valid"
);
}
}
#[test]
fn validate_migration_name_rejects_unsafe_names() {
for name in [
"",
"add index",
"a/b",
"a\\b",
"a..b",
"a.b",
"naïve",
"a/../b",
] {
assert!(
validate_migration_name(name).is_err(),
"{name} should be rejected"
);
}
}
#[test]
fn validate_datatable_path_segment_accepts_and_rejects() {
for ok in ["mydt", "my-dt", "main", "a b"] {
assert!(
validate_datatable_path_segment(ok).is_ok(),
"{ok} should be ok"
);
}
for bad in ["", "a/b", "a\\b", "..", "a..b", "../etc", "x/.."] {
assert!(
validate_datatable_path_segment(bad).is_err(),
"{bad} should be rejected"
);
}
}
// A new name must survive the git-sync path matchers; an existing one is only
// held to the escape rule, so a workspace carrying a legacy name can still save
// its settings and rename its way out.
#[test]
fn validate_new_datatable_name_requires_a_sync_safe_charset() {
for ok in ["mydt", "my-dt", "my_dt.v2", "main"] {
assert!(validate_new_datatable_name(ok).is_ok(), "{ok} should be ok");
}
for bad in [
"a b", "a*b", "a[b]", "a{b}", "a?b", "café", ".staging", "-dt", "a/b", "",
] {
assert!(
validate_new_datatable_name(bad).is_err(),
"{bad} should be rejected"
);
if bad != "a/b" && !bad.is_empty() {
assert!(
validate_datatable_path_segment(bad).is_ok(),
"{bad} should still be storable once persisted"
);
}
}
}
#[test]
fn parse_datatable_migration_diff_path_roundtrips() {
assert_eq!(
parse_datatable_migration_diff_path("mydt/20260101000001_create_users.up.sql"),
Some(("mydt".to_string(), 20260101000001))
);
// up and down map to the same (datatable, timestamp) record.
assert_eq!(
parse_datatable_migration_diff_path("mydt/20260101000001_create_users.down.sql"),
Some(("mydt".to_string(), 20260101000001))
);
// datatable names may themselves be hyphenated.
assert_eq!(
parse_datatable_migration_diff_path("my-dt/42_x.up.sql"),
Some(("my-dt".to_string(), 42))
);
}
#[test]
fn parse_datatable_migration_diff_path_rejects_malformed() {
// no slash → not a migration path
assert_eq!(parse_datatable_migration_diff_path("nofile"), None);
// filename not starting with digits → no timestamp
assert_eq!(
parse_datatable_migration_diff_path("mydt/create_users.up.sql"),
None
);
// empty filename
assert_eq!(parse_datatable_migration_diff_path("mydt/"), None);
}
async fn seed_migration(pool: &DB, w_id: &str, datatable: &str, timestamp: i64, name: &str) {
sqlx::query(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \
VALUES ($1, $2, $3, $4, 'select 1;')",
)
.bind(w_id)
.bind(datatable)
.bind(timestamp)
.bind(name)
.execute(pool)
.await
.unwrap();
}
async fn migration_keys(pool: &DB, w_id: &str) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
"SELECT datatable, name FROM datatable_migrations WHERE workspace_id = $1 \
ORDER BY datatable, timestamp",
)
.bind(w_id)
.fetch_all(pool)
.await
.unwrap()
}
// The cascade keeps each data table's migrations attached to its name when a
// data table is renamed, drops them when it is deleted, survives a swap
// (A->B, B->A) at a shared timestamp without a primary-key collision, and
// reports both ends of every move so git sync drops the stale files.
#[sqlx::test(migrations = "../migrations")]
async fn cascade_renames_and_deletes_datatable_migrations(pool: DB) {
let w_id = format!("dtmig{}", uuid::Uuid::new_v4().simple());
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test@windmill.dev')")
.bind(&w_id)
.execute(&pool)
.await
.unwrap();
// rename a -> a2, delete d, and swap sa <-> sb (both at timestamp 5000)
seed_migration(&pool, &w_id, "a", 1, "a_mig").await;
seed_migration(&pool, &w_id, "d", 1, "d_mig").await;
seed_migration(&pool, &w_id, "sa", 5000, "sa_mig").await;
seed_migration(&pool, &w_id, "sb", 5000, "sb_mig").await;
let mut tx = pool.begin().await.unwrap();
let mut changed = cascade_datatable_migration_renames_and_deletes(
&pool,
&mut tx,
&w_id,
&[
DatatableRename { from: "a".to_string(), to: "a2".to_string() },
DatatableRename { from: "sa".to_string(), to: "sb".to_string() },
DatatableRename { from: "sb".to_string(), to: "sa".to_string() },
],
&["d".to_string()],
)
.await
.unwrap();
tx.commit().await.unwrap();
assert_eq!(
migration_keys(&pool, &w_id).await,
vec![
("a2".to_string(), "a_mig".to_string()),
("sa".to_string(), "sb_mig".to_string()),
("sb".to_string(), "sa_mig".to_string()),
]
);
changed.sort();
assert_eq!(
changed,
vec![
"a/1_a_mig".to_string(),
"a2/1_a_mig".to_string(),
"d/1_d_mig".to_string(),
"sa/5000_sa_mig".to_string(),
"sa/5000_sb_mig".to_string(),
"sb/5000_sa_mig".to_string(),
"sb/5000_sb_mig".to_string(),
]
);
}
// A fork inherits its parent's migration definitions unchanged.
#[sqlx::test(migrations = "../migrations")]
async fn clone_copies_datatable_migrations_to_target(pool: DB) {
let src = format!("src{}", uuid::Uuid::new_v4().simple());
let dst = format!("dst{}", uuid::Uuid::new_v4().simple());
for w in [&src, &dst] {
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test@windmill.dev')",
)
.bind(w)
.execute(&pool)
.await
.unwrap();
}
seed_migration(&pool, &src, "customers", 1, "create_customers").await;
seed_migration(&pool, &src, "customers", 2, "add_index").await;
seed_migration(&pool, &src, "orders", 3, "create_orders").await;
let mut tx = pool.begin().await.unwrap();
clone_datatable_migrations(&mut tx, &src, &dst)
.await
.unwrap();
tx.commit().await.unwrap();
// the target ends up with an identical set, and the source is untouched.
let expected = vec![
("customers".to_string(), "create_customers".to_string()),
("customers".to_string(), "add_index".to_string()),
("orders".to_string(), "create_orders".to_string()),
];
assert_eq!(migration_keys(&pool, &dst).await, expected);
assert_eq!(migration_keys(&pool, &src).await, expected);
}
}