pub mod handle;
pub mod migrate;
pub mod registry;
#[cfg(test)]
mod handle_registry_tests;
pub use handle::{TenantHandle, TenantOpenParams};
pub use migrate::migrate_v071_to_v080;
pub use registry::{TenantRegistry, TenantRegistryParams};
use rusqlite::{Connection, OptionalExtension, params};
use solo_core::{Error, Result, TenantId};
use std::path::Path;
use crate::init::open_sqlcipher;
use crate::key_material::KeyMaterial;
use crate::migration;
pub const TENANTS_INDEX_FILENAME: &str = "tenants_index.db";
pub const TENANTS_SUBDIR: &str = "tenants";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TenantStatus {
Active,
PendingMigration,
PendingDelete,
}
impl TenantStatus {
pub fn as_sql_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingMigration => "pending_migration",
Self::PendingDelete => "pending_delete",
}
}
fn parse(s: &str) -> Result<Self> {
match s {
"active" => Ok(Self::Active),
"pending_migration" => Ok(Self::PendingMigration),
"pending_delete" => Ok(Self::PendingDelete),
other => Err(Error::storage(format!(
"unknown tenant status from registry: {other:?}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TenantRecord {
pub tenant_id: TenantId,
pub db_filename: String,
pub display_name: Option<String>,
pub created_at_ms: i64,
pub status: TenantStatus,
}
pub struct TenantsIndex {
conn: Connection,
}
impl TenantsIndex {
pub fn open(data_dir: &Path, key: &KeyMaterial) -> Result<Self> {
let path = data_dir.join(TENANTS_INDEX_FILENAME);
let mut conn = open_sqlcipher(&path, key)?;
migration::run_tenants_index_migrations(&mut conn)?;
Ok(Self { conn })
}
pub fn register(
&mut self,
tenant_id: &TenantId,
db_filename: &str,
display_name: Option<&str>,
) -> Result<()> {
self.register_with_status(tenant_id, db_filename, display_name, TenantStatus::Active)
}
pub fn register_with_status(
&mut self,
tenant_id: &TenantId,
db_filename: &str,
display_name: Option<&str>,
status: TenantStatus,
) -> Result<()> {
let now_ms: i64 = chrono::Utc::now().timestamp_millis();
let res = self.conn.execute(
"INSERT INTO tenants (tenant_id, db_filename, display_name, created_at_ms, status)
VALUES (?, ?, ?, ?, ?)",
params![
tenant_id.as_str(),
db_filename,
display_name,
now_ms,
status.as_sql_str(),
],
);
match res {
Ok(_) => Ok(()),
Err(rusqlite::Error::SqliteFailure(err, msg))
if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|| err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
{
Err(Error::conflict(format!(
"tenant already exists: {} ({})",
tenant_id,
msg.as_deref().unwrap_or("UNIQUE/PK violation")
)))
}
Err(e) => Err(Error::storage(format!("register tenant {tenant_id}: {e}"))),
}
}
pub fn set_status(
&mut self,
tenant_id: &TenantId,
status: TenantStatus,
) -> Result<()> {
self.conn
.execute(
"UPDATE tenants SET status = ? WHERE tenant_id = ?",
params![status.as_sql_str(), tenant_id.as_str()],
)
.map_err(|e| Error::storage(format!("set_status({tenant_id}): {e}")))?;
Ok(())
}
pub fn list(&self) -> Result<Vec<TenantRecord>> {
let mut stmt = self
.conn
.prepare(
"SELECT tenant_id, db_filename, display_name, created_at_ms, status
FROM tenants
ORDER BY created_at_ms ASC, tenant_id ASC",
)
.map_err(|e| Error::storage(format!("prepare list tenants: {e}")))?;
let rows = stmt
.query_map([], row_to_tenant_record)
.map_err(|e| Error::storage(format!("query list tenants: {e}")))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| Error::storage(format!("scan tenant row: {e}")))?);
}
Ok(out)
}
pub fn lookup(&self, tenant_id: &TenantId) -> Result<Option<TenantRecord>> {
self.conn
.query_row(
"SELECT tenant_id, db_filename, display_name, created_at_ms, status
FROM tenants WHERE tenant_id = ?",
params![tenant_id.as_str()],
row_to_tenant_record,
)
.optional()
.map_err(|e| Error::storage(format!("lookup tenant {tenant_id}: {e}")))
}
pub fn remove(&mut self, tenant_id: &TenantId) -> Result<()> {
self.conn
.execute(
"DELETE FROM tenants WHERE tenant_id = ?",
params![tenant_id.as_str()],
)
.map_err(|e| Error::storage(format!("remove tenant {tenant_id}: {e}")))?;
Ok(())
}
pub(crate) fn connection(&self) -> &Connection {
&self.conn
}
#[cfg(any(test, feature = "test-support"))]
pub fn from_connection_for_tests(conn: Connection) -> Self {
Self { conn }
}
}
fn row_to_tenant_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantRecord> {
let tenant_id_str: String = row.get(0)?;
let tenant_id = TenantId::new(tenant_id_str.clone()).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tenant_id in registry: {tenant_id_str}: {e}"),
)),
)
})?;
let db_filename: String = row.get(1)?;
let display_name: Option<String> = row.get(2)?;
let created_at_ms: i64 = row.get(3)?;
let status_str: String = row.get(4)?;
let status = TenantStatus::parse(&status_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{e}"),
)),
)
})?;
Ok(TenantRecord {
tenant_id,
db_filename,
display_name,
created_at_ms,
status,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fast_test_key() -> KeyMaterial {
let salt = [7u8; 16];
KeyMaterial::derive("registry-test-passphrase", &salt)
.expect("derive test key")
}
fn open_fresh(tmp: &TempDir) -> TenantsIndex {
let key = fast_test_key();
TenantsIndex::open(tmp.path(), &key).expect("open tenants_index")
}
#[test]
fn open_creates_schema() {
let tmp = TempDir::new().unwrap();
let idx = open_fresh(&tmp);
let n: i64 = idx
.conn
.query_row("SELECT COUNT(*) FROM tenants", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0);
let v: u32 = idx
.conn
.query_row(
"SELECT MAX(version) FROM schema_migrations_tenants_index",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v, 4);
}
#[test]
fn register_then_lookup() {
let tmp = TempDir::new().unwrap();
let mut idx = open_fresh(&tmp);
let t = TenantId::new("acme").unwrap();
idx.register(&t, "acme.db", Some("ACME Corp")).unwrap();
let rec = idx.lookup(&t).unwrap().expect("tenant must be present");
assert_eq!(rec.tenant_id, t);
assert_eq!(rec.db_filename, "acme.db");
assert_eq!(rec.display_name.as_deref(), Some("ACME Corp"));
assert_eq!(rec.status, TenantStatus::Active);
}
#[test]
fn register_duplicate_errors() {
let tmp = TempDir::new().unwrap();
let mut idx = open_fresh(&tmp);
let t = TenantId::new("dup").unwrap();
idx.register(&t, "dup.db", None).unwrap();
let err = idx
.register(&t, "dup-other.db", None)
.expect_err("duplicate tenant_id must error");
assert!(
matches!(err, Error::Conflict(_)),
"expected Conflict, got {err:?}"
);
}
#[test]
fn list_returns_in_creation_order() {
let tmp = TempDir::new().unwrap();
let mut idx = open_fresh(&tmp);
let ids = ["alpha", "beta", "gamma"];
for id in ids {
idx.register(
&TenantId::new(id).unwrap(),
&format!("{id}.db"),
None,
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
}
let listed = idx.list().unwrap();
assert_eq!(listed.len(), 3);
let listed_ids: Vec<&str> =
listed.iter().map(|r| r.tenant_id.as_str()).collect();
assert_eq!(listed_ids, ["alpha", "beta", "gamma"]);
}
#[test]
fn set_status_persists() {
let tmp = TempDir::new().unwrap();
let mut idx = open_fresh(&tmp);
let t = TenantId::new("statushost").unwrap();
idx.register(&t, "statushost.db", None).unwrap();
idx.set_status(&t, TenantStatus::PendingMigration).unwrap();
let rec = idx.lookup(&t).unwrap().unwrap();
assert_eq!(rec.status, TenantStatus::PendingMigration);
idx.set_status(&t, TenantStatus::PendingDelete).unwrap();
let rec = idx.lookup(&t).unwrap().unwrap();
assert_eq!(rec.status, TenantStatus::PendingDelete);
}
#[test]
fn remove_idempotent_on_missing() {
let tmp = TempDir::new().unwrap();
let mut idx = open_fresh(&tmp);
let absent = TenantId::new("ghost").unwrap();
idx.remove(&absent).expect("idempotent remove");
assert_eq!(idx.list().unwrap().len(), 0);
}
}