use crate::pool::{Engine, Exec, SqlxPool};
use crate::sql::Sql;
use alibi_core::config::AuthConfig;
use alibi_core::error::{AuthError, AuthResult, DatabaseError};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) struct Migration {
pub(crate) name: &'static str,
pub(crate) sqlite: &'static str,
pub(crate) postgres: &'static str,
}
const AUTH_SCHEMA: Migration = Migration {
name: "m20261013_000001_typescript_auth_schema",
sqlite: alibi_schema_registry::SQLITE_AUTH_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_AUTH_SCHEMA,
};
const OAUTH_CLIENTS: Migration = Migration {
name: "m20261013_000002_typescript_oauth_schema",
sqlite: alibi_schema_registry::SQLITE_OAUTH_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_OAUTH_SCHEMA,
};
const SSO: Migration = Migration {
name: "m20261013_000003_typescript_sso_schema",
sqlite: alibi_schema_registry::SQLITE_SSO_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_SSO_SCHEMA,
};
const SCIM_MANAGED: Migration = Migration {
name: "m20261014_000004_typescript_scim_managed_schema",
sqlite: alibi_schema_registry::SQLITE_SCIM_MANAGED_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_SCIM_MANAGED_SCHEMA,
};
const SCIM_BINDING: Migration = Migration {
name: "m20261014_000005_typescript_scim_binding_schema",
sqlite: alibi_schema_registry::SQLITE_SCIM_BINDING_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_SCIM_BINDING_SCHEMA,
};
const SCIM_CANONICAL: Migration = Migration {
name: "m20261014_000006_typescript_scim_canonical_schema",
sqlite: alibi_schema_registry::SQLITE_SCIM_CANONICAL_SCHEMA,
postgres: alibi_schema_registry::POSTGRES_SCIM_CANONICAL_SCHEMA,
};
pub async fn run_migrations(pool: &SqlxPool) -> AuthResult<()> {
apply(
pool,
"better_auth_migrations",
&[
AUTH_SCHEMA,
OAUTH_CLIENTS,
SSO,
SCIM_MANAGED,
SCIM_BINDING,
SCIM_CANONICAL,
],
)
.await
}
pub(crate) async fn apply(
pool: &SqlxPool,
ledger: &'static str,
migrations: &[Migration],
) -> AuthResult<()> {
apply_scoped(pool, ledger, migrations, None).await
}
pub(crate) async fn run_migrations_scoped(
pool: &SqlxPool,
config: &Arc<AuthConfig>,
) -> AuthResult<()> {
apply_scoped(
pool,
"better_auth_migrations",
&[
AUTH_SCHEMA,
OAUTH_CLIENTS,
SSO,
SCIM_MANAGED,
SCIM_BINDING,
SCIM_CANONICAL,
],
Some(config),
)
.await
}
async fn apply_scoped(
pool: &SqlxPool,
ledger: &'static str,
migrations: &[Migration],
config: Option<&Arc<AuthConfig>>,
) -> AuthResult<()> {
let mut exec = Exec::pool(pool);
if let Some(config) = config {
if pool.engine() == Engine::Postgres
&& let Some(schema) = &config.advanced.database.schema_name
{
let mut create = Sql::with(pool.engine(), "CREATE SCHEMA IF NOT EXISTS ");
create.ident(schema);
_ = exec.execute(create).await?;
}
exec = exec.with_config(config.as_ref());
}
let backend = pool.engine();
let applied_at = match backend {
Engine::Sqlite => "integer",
Engine::Postgres => "bigint",
};
let mut install = Sql::with(backend, "CREATE TABLE IF NOT EXISTS ");
install.ident(ledger);
install.push(" ( \"version\" varchar NOT NULL PRIMARY KEY, \"applied_at\" ");
install.push(applied_at);
install.push(" NOT NULL )");
_ = exec.execute(install).await?;
let mut select = Sql::with(backend, "SELECT \"version\" FROM ");
select.ident(ledger);
select.push(" ORDER BY \"version\" ASC");
let applied: Vec<String> = exec.fetch_all_scalar(select).await?;
let missing: Vec<String> = applied
.iter()
.filter(|version| !migrations.iter().any(|migration| migration.name == *version))
.map(|version| {
format!("Migration file of version '{version}' is missing, this migration has been applied but its file is missing")
})
.collect();
if !missing.is_empty() {
return Err(AuthError::Database(DatabaseError::Query(format!(
"Custom Error: {}",
missing.join("\n")
))));
}
for migration in migrations
.iter()
.filter(|migration| !applied.iter().any(|version| version == migration.name))
{
let mut transaction = pool.begin(false).await?;
transaction.config = config.cloned();
let exec = Exec::tx(&transaction);
exec.execute_script(match backend {
Engine::Sqlite => migration.sqlite,
Engine::Postgres => migration.postgres,
})
.await?;
let applied_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_error| AuthError::internal("System time is before the Unix epoch"))?
.as_secs();
let mut record = Sql::with(backend, "INSERT INTO ");
record.ident(ledger);
record.push(" (\"version\", \"applied_at\") VALUES (");
record.bind(migration.name);
record.push(", ");
record.bind(i64::try_from(applied_at).unwrap_or(i64::MAX));
record.push(")");
_ = exec.execute(record).await?;
transaction.commit().await?;
}
Ok(())
}
pub(crate) async fn has_table(exec: Exec<'_>, table: &str) -> AuthResult<bool> {
let mut sql = Sql::new(exec.engine());
match exec.engine() {
Engine::Sqlite => sql.push(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name <> 'sqlite_sequence' AND name = ",
),
Engine::Postgres => {
sql.push("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ");
if let Some(schema) = exec.schema_name() {
sql.bind(schema);
} else {
sql.push("CURRENT_SCHEMA()");
}
sql.push(" AND table_type = 'BASE TABLE' AND table_name = ");
}
}
sql.bind(table);
Ok(exec.fetch_scalar::<i64>(sql).await?.unwrap_or_default() > 0)
}
pub(crate) async fn has_column(exec: Exec<'_>, table: &str, column: &str) -> AuthResult<bool> {
let mut sql = Sql::new(exec.engine());
match exec.engine() {
Engine::Sqlite => {
sql.push("SELECT COUNT(*) FROM pragma_table_info(");
sql.bind(table);
sql.push(") WHERE name = ");
}
Engine::Postgres => {
sql.push("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = ");
if let Some(schema) = exec.schema_name() {
sql.bind(schema);
} else {
sql.push("CURRENT_SCHEMA()");
}
sql.push(" AND table_name = ");
sql.bind(table);
sql.push(" AND column_name = ");
}
}
sql.bind(column);
Ok(exec.fetch_scalar::<i64>(sql).await?.unwrap_or_default() > 0)
}