use crate::migrate::{MigrateError, Migration, MigrationScope};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::core::Column as _;
#[cfg(feature = "postgres")]
use crate::sql::sqlx::PgPool;
use sqlx::Database;
use super::error::TenancyError;
use super::org::{Org, StorageMode};
use super::pools::TenantPools;
#[derive(Debug, Default)]
pub struct TenantMigrationReport {
pub tenants: Vec<TenantMigrationOutcome>,
}
impl TenantMigrationReport {
#[must_use]
pub fn all_ok(&self) -> bool {
self.tenants.iter().all(|t| t.error.is_none())
}
#[must_use]
pub fn failure_count(&self) -> usize {
self.tenants.iter().filter(|t| t.error.is_some()).count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chain {
System,
Project,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantMigrationEvent {
Planned {
tenants: usize,
},
TenantStarted {
slug: String,
index: usize,
total: usize,
},
Migration {
slug: String,
chain: Chain,
event: crate::migrate::MigrationEvent,
},
TenantFinished {
slug: String,
index: usize,
total: usize,
applied: usize,
error: Option<String>,
},
}
pub trait TenantMigrationObserver: Send + Sync {
fn on_event(&self, event: TenantMigrationEvent);
}
impl<F> TenantMigrationObserver for F
where
F: Fn(TenantMigrationEvent) + Send + Sync,
{
fn on_event(&self, event: TenantMigrationEvent) {
self(event);
}
}
fn chain_observer<'a>(
observer: Option<&'a dyn TenantMigrationObserver>,
slug: &'a str,
chain: Chain,
) -> Option<impl crate::migrate::MigrationObserver + 'a> {
observer.map(move |observer| {
move |event: crate::migrate::MigrationEvent| {
observer.on_event(TenantMigrationEvent::Migration {
slug: slug.to_owned(),
chain,
event,
});
}
})
}
#[derive(Debug)]
pub struct TenantMigrationOutcome {
pub slug: String,
pub applied: Vec<Migration>,
pub error: Option<TenancyError>,
}
pub(crate) const SYSTEM_LEDGER: &str = "__rustango_system_migrations__";
async fn migrate_with_system<F, Fut>(
pool: &crate::sql::Pool,
chain: &crate::migrate::make::SystemChain,
scope: MigrationScope,
dirs: &[PathBuf],
observer: Option<&dyn crate::migrate::MigrationObserver>,
signals: crate::migrate::Signals<'_>,
project: F,
) -> Result<Vec<Migration>, TenancyError>
where
F: FnOnce(crate::migrate::LockHeld) -> Fut,
Fut: std::future::Future<Output = Result<Vec<Migration>, crate::migrate::MigrateError>>,
{
let applied = crate::migrate::chains::migrate_chains(
pool, chain, scope, dirs, observer, signals, project,
)
.await?;
let mut all = applied.system;
all.extend(applied.project);
Ok(all)
}
async fn each_dir<'d, G, Fut>(
project: &'d ScopedProject,
mut run: G,
) -> Result<Vec<Migration>, MigrateError>
where
G: FnMut(&'d Path) -> Fut,
Fut: std::future::Future<Output = Result<Vec<Migration>, MigrateError>>,
{
let mut all: Vec<Migration> = Vec::new();
for (source, dir) in project.sources.iter().zip(&project.dirs) {
match run(dir).await {
Ok(applied) => all.extend(applied),
Err(e) if project.dirs.len() == 1 => return Err(e),
Err(e) => {
let before = if all.is_empty() {
String::new()
} else {
let names: Vec<&str> = all.iter().map(|m| m.name.as_str()).collect();
format!("; applied before it: {}", names.join(", "))
};
return Err(MigrateError::Validation(format!(
"{}: {e}{before}",
source.display()
)));
}
}
}
Ok(all)
}
pub(crate) struct ScopedProject {
root: PathBuf,
sources: Vec<PathBuf>,
dirs: Vec<PathBuf>,
_copies: Vec<ScopedDir>,
}
impl ScopedProject {
pub(crate) async fn of(dir: &Path, scope: MigrationScope) -> Result<Self, TenancyError> {
let mut dirs = Vec::new();
let mut copies = Vec::new();
let sources = crate::migrate::file::project_migration_dirs(dir);
for source in &sources {
let copy = scoped_subset(source, scope).await?;
dirs.push(copy.path(source).to_path_buf());
copies.push(copy);
}
Ok(Self {
root: crate::migrate::file::project_root_of(dir).to_path_buf(),
sources,
dirs,
_copies: copies,
})
}
pub(crate) fn dirs(&self) -> &[PathBuf] {
&self.dirs
}
fn system_chain(
&self,
scope: crate::core::ModelScope,
) -> Result<crate::migrate::make::SystemChain, MigrateError> {
crate::migrate::make::SystemChain::prepare(&self.root, &[scope])
}
}
pub async fn migrate_registry<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
) -> Result<Vec<Migration>, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
migrate_registry_pool(&pools.registry_pool(), dir).await
}
pub async fn migrate_registry_pool(
registry: &crate::sql::Pool,
dir: &Path,
) -> Result<Vec<Migration>, TenancyError> {
info!(target: "rustango::tenancy", "applying registry-scoped migrations");
let project = ScopedProject::of(dir, MigrationScope::Registry).await?;
let chain = project.system_chain(crate::core::ModelScope::Registry)?;
let applied = migrate_with_system(
registry,
&chain,
MigrationScope::Registry,
project.dirs(),
None,
crate::migrate::Signals::Skip,
|held| {
each_dir(&project, move |d| {
crate::migrate::migrate_pool_locked(held, registry, d, None)
})
},
)
.await?;
registry_after_migrate(registry).await?;
info!(
target: "rustango::tenancy",
applied = applied.len(),
"registry migrations done"
);
Ok(applied)
}
async fn registry_after_migrate(registry: &crate::sql::Pool) -> Result<(), TenancyError> {
crate::migrate::manage::ensure_bootstrap_tables(registry).await?;
if let Err(e) = crate::contenttypes::ensure_seeded(registry).await {
tracing::warn!(
target: "rustango::tenancy",
error = %e,
"contenttypes::ensure_seeded failed for registry pool",
);
}
normalise_datetimes_quietly(registry, "registry").await;
Ok(())
}
pub(crate) struct MovedTo {
pub(crate) system: Vec<Migration>,
pub(crate) applied: Vec<Migration>,
pub(crate) unapplied: Vec<Migration>,
}
pub(crate) async fn migrate_registry_to(
registry: &crate::sql::Pool,
dir: &Path,
target: &str,
) -> Result<Option<MovedTo>, TenancyError> {
let project = ScopedProject::of(dir, MigrationScope::Registry).await?;
let holds = |d: &PathBuf| {
rustango::migrate::file::list_dir(d).is_ok_and(|all| all.iter().any(|m| m.name == target))
};
let Some(target_dir) = project.dirs().iter().find(|d| holds(d)) else {
return Ok(None);
};
let chain = project.system_chain(crate::core::ModelScope::Registry)?;
let unapplied = std::sync::Mutex::new(Vec::new());
let slot = &unapplied;
let ran = crate::migrate::chains::migrate_chains(
registry,
&chain,
MigrationScope::Registry,
project.dirs(),
None,
crate::migrate::Signals::Skip,
|held| async move {
let moved = crate::migrate::migrate_to_locked(
held,
registry,
target_dir,
target,
crate::migrate::LEDGER_TABLE,
)
.await?;
*slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = moved.unapplied;
Ok(moved.applied)
},
)
.await?;
registry_after_migrate(registry).await?;
Ok(Some(MovedTo {
system: ran.system,
applied: ran.project,
unapplied: unapplied
.into_inner()
.unwrap_or_else(std::sync::PoisonError::into_inner),
}))
}
#[cfg(feature = "postgres")]
pub async fn migrate_tenants(
pools: &TenantPools,
dir: &Path,
registry_url: &str,
) -> Result<TenantMigrationReport, TenancyError> {
migrate_tenants_opts(pools, dir, registry_url, None).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_tenants_with_progress(
pools: &TenantPools,
dir: &Path,
registry_url: &str,
observer: &dyn TenantMigrationObserver,
) -> Result<TenantMigrationReport, TenancyError> {
migrate_tenants_opts(pools, dir, registry_url, Some(observer)).await
}
#[cfg(feature = "postgres")]
async fn migrate_tenants_opts(
pools: &TenantPools,
dir: &Path,
registry_url: &str,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<TenantMigrationReport, TenancyError> {
let project = ScopedProject::of(dir, MigrationScope::Tenant).await?;
let chain = project.system_chain(crate::core::ModelScope::Tenant)?;
let migrations_in_scope = rustango::migrate::file::list_each(project.dirs())
.map(|m| m.len())
.unwrap_or(0);
let orgs: Vec<Org> = Org::objects()
.where_(Org::active.eq(true))
.fetch_on(pools.registry())
.await?;
info!(
target: "rustango::tenancy",
tenants = orgs.len(),
migrations = migrations_in_scope,
dir = %dir.display(),
"applying tenant-scoped migrations"
);
if migrations_in_scope == 0 && !orgs.is_empty() {
warn!(
target: "rustango::tenancy",
dir = %dir.display(),
"no tenant-scoped migrations found in dir; tenants will record applied=0 — \
pass the flat migrations directory or a project root containing one"
);
}
let total = orgs.len();
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::Planned { tenants: total });
}
let mut report = TenantMigrationReport::default();
for (i, org) in orgs.iter().enumerate() {
let index = i + 1;
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::TenantStarted {
slug: org.slug.clone(),
index,
total,
});
}
let outcome =
run_for_one_tenant(pools, org, &project, &chain, registry_url, observer).await;
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::TenantFinished {
slug: org.slug.clone(),
index,
total,
applied: outcome.as_ref().map_or(0, Vec::len),
error: outcome.as_ref().err().map(ToString::to_string),
});
}
match &outcome {
Ok(applied) => info!(
target: "rustango::tenancy",
slug = %org.slug,
applied = applied.len(),
"tenant migrations done"
),
Err(e) => warn!(
target: "rustango::tenancy",
slug = %org.slug,
error = %e,
"tenant migration failed; continuing with remaining tenants"
),
}
report.tenants.push(match outcome {
Ok(applied) => TenantMigrationOutcome {
slug: org.slug.clone(),
applied,
error: None,
},
Err(error) => TenantMigrationOutcome {
slug: org.slug.clone(),
applied: Vec::new(),
error: Some(error),
},
});
}
Ok(report)
}
pub async fn migrate_tenants_db<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
registry_url: &str,
) -> Result<TenantMigrationReport, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
migrate_tenants_db_opts(pools, dir, registry_url, None).await
}
pub async fn migrate_tenants_db_with_progress<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
registry_url: &str,
observer: &dyn TenantMigrationObserver,
) -> Result<TenantMigrationReport, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
migrate_tenants_db_opts(pools, dir, registry_url, Some(observer)).await
}
pub async fn migrate_one_tenant<DB: Database>(
pools: &TenantPools<DB>,
org: &Org,
dir: &Path,
registry_url: &str,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<Vec<Migration>, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let project = ScopedProject::of(dir, MigrationScope::Tenant).await?;
let chain = project.system_chain(crate::core::ModelScope::Tenant)?;
#[cfg(feature = "postgres")]
if matches!(
StorageMode::parse(&org.storage_mode),
Ok(StorageMode::Schema)
) {
let pg_pools = (pools as &dyn std::any::Any)
.downcast_ref::<TenantPools<sqlx::Postgres>>()
.ok_or_else(|| {
TenancyError::Validation(format!(
"org `{}` is schema-mode but the registry is not Postgres",
org.slug
))
})?;
return run_for_one_tenant(pg_pools, org, &project, &chain, registry_url, observer).await;
}
let _ = registry_url;
run_for_one_tenant_db(pools, org, &project, &chain, observer).await
}
async fn migrate_tenants_db_opts<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
_registry_url: &str,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<TenantMigrationReport, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
use crate::sql::FetcherPool as _;
let project = ScopedProject::of(dir, MigrationScope::Tenant).await?;
let chain = project.system_chain(crate::core::ModelScope::Tenant)?;
let registry_pool = pools.registry_pool();
let orgs: Vec<Org> = Org::objects()
.where_(Org::active.eq(true))
.fetch(®istry_pool)
.await?;
info!(
target: "rustango::tenancy",
tenants = orgs.len(),
dir = %dir.display(),
"applying tenant-scoped migrations (db-mode only)"
);
let total = orgs.len();
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::Planned { tenants: total });
}
let mut report = TenantMigrationReport::default();
for (i, org) in orgs.iter().enumerate() {
let index = i + 1;
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::TenantStarted {
slug: org.slug.clone(),
index,
total,
});
}
let outcome = run_for_one_tenant_db(pools, org, &project, &chain, observer).await;
if let Some(observer) = observer {
observer.on_event(TenantMigrationEvent::TenantFinished {
slug: org.slug.clone(),
index,
total,
applied: outcome.as_ref().map_or(0, Vec::len),
error: outcome.as_ref().err().map(ToString::to_string),
});
}
match &outcome {
Ok(applied) => info!(
target: "rustango::tenancy",
slug = %org.slug,
applied = applied.len(),
"tenant migrations done"
),
Err(e) => warn!(
target: "rustango::tenancy",
slug = %org.slug,
error = %e,
"tenant migration failed; continuing with remaining tenants"
),
}
report.tenants.push(match outcome {
Ok(applied) => TenantMigrationOutcome {
slug: org.slug.clone(),
applied,
error: None,
},
Err(error) => TenantMigrationOutcome {
slug: org.slug.clone(),
applied: Vec::new(),
error: Some(error),
},
});
}
Ok(report)
}
async fn run_for_one_tenant_db<DB: Database>(
pools: &TenantPools<DB>,
org: &Org,
project: &ScopedProject,
chain: &crate::migrate::make::SystemChain,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<Vec<Migration>, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let mode = StorageMode::parse(&org.storage_mode).map_err(|got| {
TenancyError::Validation(format!(
"org `{}` has unknown storage_mode `{got}`",
org.slug
))
})?;
if !matches!(mode, StorageMode::Database) {
return Err(TenancyError::Validation(format!(
"org `{}` is schema-mode but migrate_tenants_db only handles \
database-mode tenants (schema-mode is PG-only by language)",
org.slug,
)));
}
let tenant_pool = pools.database_pool_for_org(org).await?;
let inner_pool = match &tenant_pool {
super::pools::TenantPool::Database { pool } => crate::sql::Pool::from((**pool).clone()),
#[cfg(feature = "postgres")]
super::pools::TenantPool::Schema { .. } => {
unreachable!("database_pool_for_org rejects schema-mode")
}
};
let system_progress = chain_observer(observer, &org.slug, Chain::System);
let project_progress = chain_observer(observer, &org.slug, Chain::Project);
let applied = migrate_with_system(
&inner_pool,
chain,
MigrationScope::Tenant,
project.dirs(),
system_progress
.as_ref()
.map(|o| o as &dyn crate::migrate::MigrationObserver),
crate::migrate::Signals::Fire {
tenant: Some(&org.slug),
},
|held| {
let observer = project_progress
.as_ref()
.map(|o| o as &dyn crate::migrate::MigrationObserver);
let pool = &inner_pool;
each_dir(project, move |d| {
crate::migrate::migrate_pool_locked(held, pool, d, observer)
})
},
)
.await?;
crate::migrate::manage::ensure_tenant_bootstrap_tables(&inner_pool).await?;
if let Err(e) = super::permissions::auto_create_permissions_pool(&inner_pool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "auto_create_permissions_pool failed for database-mode tenant");
}
if let Err(e) = crate::contenttypes::ensure_seeded(&inner_pool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "contenttypes::ensure_seeded failed for database-mode tenant");
}
normalise_datetimes_quietly(&inner_pool, &org.slug).await;
Ok(applied)
}
#[allow(unused_variables)]
async fn normalise_datetimes_quietly(pool: &crate::sql::Pool, scope: &str) {
#[cfg(feature = "sqlite")]
match crate::migrate::sqlite_datetime::normalise_sqlite_datetimes(pool).await {
Ok(fixed) if !fixed.is_clean() => tracing::info!(
target: "rustango::tenancy",
scope = %scope,
rows = fixed.rows,
columns = ?fixed.columns,
"normalised SQLite datetime values written before 0.57.11",
),
Ok(_) => {}
Err(e) => tracing::warn!(
target: "rustango::tenancy",
scope = %scope,
error = %e,
"SQLite datetime normalisation failed; timestamps written before \
0.57.11 may not compare correctly until it succeeds",
),
}
}
pub async fn migrate_tenants_dyn<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
registry_url: &str,
) -> Result<TenantMigrationReport, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
migrate_tenants_dyn_with_progress(pools, dir, registry_url, None).await
}
pub async fn migrate_tenants_dyn_with_progress<DB: Database>(
pools: &TenantPools<DB>,
dir: &Path,
registry_url: &str,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<TenantMigrationReport, TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
#[cfg(feature = "postgres")]
if let Some(pg) = (pools as &dyn std::any::Any).downcast_ref::<TenantPools<sqlx::Postgres>>() {
return migrate_tenants_opts(pg, dir, registry_url, observer).await;
}
migrate_tenants_db_opts(pools, dir, registry_url, observer).await
}
#[cfg(all(feature = "postgres", feature = "passkey"))]
const PASSKEY_TABLE: &str = "rustango_webauthn_credentials";
#[cfg(all(feature = "postgres", feature = "passkey"))]
async fn existed_here(pool: &crate::sql::Pool, table: &str) -> bool {
crate::migrate::try_table_exists_here(pool, table)
.await
.unwrap_or(false)
}
#[cfg(all(feature = "postgres", feature = "passkey"))]
async fn warn_hidden_public_passkeys(registry: &crate::sql::Pool, slug: &str, schema: &str) {
use crate::sql::CounterPool as _;
if !existed_here(registry, PASSKEY_TABLE).await {
return;
}
let rows = crate::passkey::WebauthnCredential::objects()
.count(registry)
.await
.unwrap_or(0);
if rows > 0 {
tracing::warn!(
target: "rustango::tenancy",
slug,
rows,
"tenant `{slug}` now has its own {PASSKEY_TABLE}, which hides the {rows} row(s) in \
public.{PASSKEY_TABLE}: move this tenant's rows into \"{schema}\".{PASSKEY_TABLE} \
by hand (see UPGRADING, 0.60.5)"
);
}
}
#[cfg(feature = "postgres")]
async fn run_for_one_tenant(
pools: &TenantPools,
org: &Org,
project: &ScopedProject,
chain: &crate::migrate::make::SystemChain,
registry_url: &str,
observer: Option<&dyn TenantMigrationObserver>,
) -> Result<Vec<Migration>, TenancyError> {
let system_progress = chain_observer(observer, &org.slug, Chain::System);
let system_progress = system_progress
.as_ref()
.map(|o| o as &dyn crate::migrate::MigrationObserver);
let project_progress = chain_observer(observer, &org.slug, Chain::Project);
let mode = StorageMode::parse(&org.storage_mode).map_err(|got| {
TenancyError::Validation(format!(
"org `{}` has unknown storage_mode `{got}`",
org.slug
))
})?;
match mode {
StorageMode::Schema => {
let schema = org.effective_schema().to_owned();
let pool = build_schema_scoped_pool(registry_url, &schema).await?;
let dbpool: crate::sql::Pool = pool.clone().into();
#[cfg(feature = "passkey")]
let reads_public_passkeys = existed_here(&dbpool, "rustango_users").await
&& !existed_here(&dbpool, PASSKEY_TABLE).await;
let run = async {
let applied = migrate_with_system(
&dbpool,
chain,
MigrationScope::Tenant,
project.dirs(),
system_progress,
crate::migrate::Signals::Fire {
tenant: Some(&org.slug),
},
|held| {
let observer = project_progress
.as_ref()
.map(|o| o as &dyn crate::migrate::MigrationObserver);
let pool = &pool;
each_dir(project, move |d| {
crate::migrate::migrate_locked(held, pool, d, observer)
})
},
)
.await?;
crate::migrate::manage::ensure_tenant_bootstrap_tables(&dbpool).await?;
Ok::<_, TenancyError>(applied)
}
.await;
#[cfg(feature = "passkey")]
if reads_public_passkeys && existed_here(&dbpool, PASSKEY_TABLE).await {
warn_hidden_public_passkeys(&pools.registry_pool(), &org.slug, &schema).await;
}
let applied = run?;
if let Err(e) = super::permissions::auto_create_permissions_pool(&dbpool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "auto_create_permissions_pool failed for schema-mode tenant");
}
if let Err(e) = crate::contenttypes::ensure_seeded(&dbpool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "contenttypes::ensure_seeded failed for schema-mode tenant");
}
normalise_datetimes_quietly(&dbpool, &org.slug).await;
pool.close().await;
Ok(applied)
}
StorageMode::Database => {
let tenant_pool = pools.pool_for_org(org).await?;
let dbpool: crate::sql::Pool = tenant_pool.pool().clone().into();
let applied = migrate_with_system(
&dbpool,
chain,
MigrationScope::Tenant,
project.dirs(),
system_progress,
crate::migrate::Signals::Fire {
tenant: Some(&org.slug),
},
|held| {
let observer = project_progress
.as_ref()
.map(|o| o as &dyn crate::migrate::MigrationObserver);
let pool = tenant_pool.pool();
each_dir(project, move |d| {
crate::migrate::migrate_locked(held, pool, d, observer)
})
},
)
.await?;
crate::migrate::manage::ensure_tenant_bootstrap_tables(&dbpool).await?;
if let Err(e) = super::permissions::auto_create_permissions_pool(&dbpool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "auto_create_permissions_pool failed for database-mode tenant");
}
if let Err(e) = crate::contenttypes::ensure_seeded(&dbpool).await {
tracing::warn!(target: "rustango::tenancy", slug = %org.slug, error = %e, "contenttypes::ensure_seeded failed for database-mode tenant");
}
normalise_datetimes_quietly(&dbpool, &org.slug).await;
Ok(applied)
}
}
}
#[cfg(feature = "postgres")]
async fn build_schema_scoped_pool(
registry_url: &str,
schema: &str,
) -> Result<PgPool, TenancyError> {
let bootstrap = PgPool::connect(registry_url).await?;
let origin = ensure_schema(&bootstrap, schema).await;
bootstrap.close().await;
if origin? == SchemaOrigin::Foreign {
warn!(
target: "rustango::tenancy",
schema,
"the tenant's schema already holds tables but no rustango ledger, so another app \
may own it; migrating adopts it. Move the tenant to a schema of its own: a later \
release refuses this"
);
}
super::pools::connect_schema_scoped(registry_url, schema, 2).await
}
#[cfg(feature = "postgres")]
#[derive(Debug, PartialEq, Eq)]
enum SchemaOrigin {
Created,
Kept,
Foreign,
}
#[cfg(feature = "postgres")]
async fn ensure_schema(pg: &PgPool, schema: &str) -> Result<SchemaOrigin, sqlx::Error> {
use crate::migrate::{LEDGER_TABLE, SYSTEM_LEDGER_TABLE};
let create = format!("CREATE SCHEMA {}", crate::sql::quote_with(schema, '"'));
match rustango::sql::sqlx::query(&create).execute(pg).await {
Ok(_) => return Ok(SchemaOrigin::Created),
Err(sqlx::Error::Database(e))
if e.code().as_deref() == Some("42P06")
|| (e.code().as_deref() == Some("23505")
&& e.constraint() == Some("pg_namespace_nspname_index")) => {}
Err(e) => return Err(e),
}
let (tables, ledgers): (i64, i64) = rustango::sql::sqlx::query_as(
"SELECT count(*), count(*) FILTER (WHERE c.relname IN ($2, $3)) \
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')",
)
.bind(schema)
.bind(LEDGER_TABLE)
.bind(SYSTEM_LEDGER_TABLE)
.fetch_one(pg)
.await?;
Ok(if tables > 0 && ledgers == 0 {
SchemaOrigin::Foreign
} else {
SchemaOrigin::Kept
})
}
pub(crate) async fn scoped_subset(
dir: &Path,
scope: MigrationScope,
) -> Result<ScopedDir, TenancyError> {
let all = rustango::migrate::file::list_dir(dir)?;
if all.iter().all(|m| m.scope == scope) {
return Ok(ScopedDir::Original);
}
Ok(ScopedDir::Owned(scoped_copy(dir, scope)?))
}
pub(crate) fn scoped_copy(
dir: &Path,
scope: MigrationScope,
) -> Result<tempfile::TempDir, TenancyError> {
let temp = scoped_tempdir()?;
for mig in rustango::migrate::file::list_dir(dir)? {
if mig.scope == scope {
let target = temp.path().join(format!("{}.json", mig.name));
rustango::migrate::file::write(&target, &mig)?;
}
}
Ok(temp)
}
pub(crate) enum ScopedDir {
Original,
Owned(tempfile::TempDir),
}
impl ScopedDir {
pub(crate) fn path<'a>(&'a self, original: &'a Path) -> &'a Path {
match self {
Self::Owned(temp) => temp.path(),
Self::Original => original,
}
}
}
fn scoped_tempdir() -> Result<tempfile::TempDir, TenancyError> {
let mut builder = tempfile::Builder::new();
builder.prefix("rustango_tenancy_scoped_");
#[cfg(unix)]
builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700));
builder.tempdir().map_err(|e| {
TenancyError::Validation(format!("could not create scoped-migration tempdir: {e}"))
})
}
#[cfg(all(test, feature = "postgres"))]
mod schema_pool_tests {
use super::build_schema_scoped_pool;
#[tokio::test]
async fn schema_scoped_pools_have_their_own_scope_key() {
let Ok(url) = std::env::var("DATABASE_URL") else {
return;
};
let pid = std::process::id();
let (a, b) = (format!("scope \"a\" {pid}"), format!("scope_b_{pid}"));
let registry = crate::sql::Pool::connect(&url).await.expect("registry");
let pa = crate::sql::Pool::Postgres(build_schema_scoped_pool(&url, &a).await.expect("a"));
let pb = crate::sql::Pool::Postgres(build_schema_scoped_pool(&url, &b).await.expect("b"));
let current: (String,) = sqlx::query_as("SELECT current_schema()::text")
.fetch_one(pa.as_postgres().expect("pg"))
.await
.expect("current_schema");
assert_eq!(current.0, a, "search_path not applied");
let keys = [registry.scope_key(), pa.scope_key(), pb.scope_key()];
assert_eq!(pa.clone().scope_key(), keys[1], "a clone keeps its scope");
pa.close().await;
pb.close().await;
for s in [&a, &b] {
let drop = format!(
"DROP SCHEMA IF EXISTS {} CASCADE",
crate::sql::quote_with(s, '"')
);
let _ = sqlx::query(&drop)
.execute(registry.as_postgres().expect("pg"))
.await;
}
assert_ne!(keys[0], keys[1], "schema pool shares the registry's scope");
assert_ne!(keys[1], keys[2], "two schema pools share a scope");
}
#[tokio::test]
async fn a_foreign_schema_is_told_from_the_tenants_own() {
use super::{ensure_schema, SchemaOrigin};
let Ok(url) = std::env::var("DATABASE_URL") else {
return;
};
let pg = crate::sql::sqlx::PgPool::connect(&url)
.await
.expect("registry");
let s = format!("origin_{}", std::process::id());
let q = crate::sql::quote_with(&s, '"');
let _ = sqlx::query(&format!("DROP SCHEMA IF EXISTS {q} CASCADE"))
.execute(&pg)
.await;
assert_eq!(ensure_schema(&pg, &s).await.unwrap(), SchemaOrigin::Created);
assert_eq!(ensure_schema(&pg, &s).await.unwrap(), SchemaOrigin::Kept);
sqlx::query(&format!("CREATE TABLE {q}.app_data (id INT)"))
.execute(&pg)
.await
.unwrap();
assert_eq!(ensure_schema(&pg, &s).await.unwrap(), SchemaOrigin::Foreign);
let ledger = crate::migrate::LEDGER_TABLE;
sqlx::query(&format!("CREATE TABLE {q}.\"{ledger}\" (name TEXT)"))
.execute(&pg)
.await
.unwrap();
assert_eq!(ensure_schema(&pg, &s).await.unwrap(), SchemaOrigin::Kept);
let _ = sqlx::query(&format!("DROP SCHEMA {q} CASCADE"))
.execute(&pg)
.await;
}
}
#[cfg(test)]
mod scoped_subset_tests {
use super::{scoped_subset, MigrationScope};
fn write(dir: &std::path::Path, name: &str, scope: MigrationScope) {
let mig = crate::migrate::Migration {
name: name.to_owned(),
created_at: "2026-10-29T00:00:00Z".into(),
prev: None,
atomic: true,
scope,
replaces: Vec::new(),
merges: Vec::new(),
snapshot: serde_json::from_value(serde_json::json!({ "tables": [] })).unwrap(),
forward: Vec::new(),
};
crate::migrate::file::write(&dir.join(format!("{name}.json")), &mig).unwrap();
}
#[tokio::test]
async fn the_scoped_copy_is_fresh_and_private() {
let src = tempfile::tempdir().unwrap();
write(src.path(), "0001_reg", MigrationScope::Registry);
write(src.path(), "0002_ten", MigrationScope::Tenant);
let pid = std::process::id();
let planted: Vec<_> = (0..256)
.map(|n| std::env::temp_dir().join(format!("rustango_tenancy_scoped_{pid}_{n}")))
.collect();
for p in &planted {
std::fs::create_dir_all(p).unwrap();
write(p, "0099_planted", MigrationScope::Registry);
}
let scoped = scoped_subset(src.path(), MigrationScope::Registry).await;
for p in &planted {
let _ = std::fs::remove_dir_all(p);
}
let scoped = scoped.expect("scoped copy");
let path = scoped.path(src.path()).to_path_buf();
let names: Vec<String> = crate::migrate::file::list_dir(&path)
.unwrap()
.into_iter()
.map(|m| m.name)
.collect();
assert_eq!(names, ["0001_reg"], "{}", path.display());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "{mode:o}");
}
drop(scoped);
assert!(!path.exists(), "left behind: {}", path.display());
}
}