refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
+232
-152
@@ -1,23 +1,27 @@
|
||||
use crate::db::topology::Topology;
|
||||
use crate::identity::TenantId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegistryIssueType {
|
||||
DuplicateSlug,
|
||||
InvalidTargetType,
|
||||
InvalidStatus,
|
||||
MissingOwner,
|
||||
MissingDatabase,
|
||||
MissingTenant,
|
||||
MissingTarget,
|
||||
CorruptDatabase,
|
||||
AccessFailure,
|
||||
TrueOrphan,
|
||||
Conflict,
|
||||
StaleReservation,
|
||||
TenantAdminHasIsolatedContent,
|
||||
InvalidStatus,
|
||||
InvalidTargetType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryIssue {
|
||||
pub slug: String,
|
||||
pub target_type: String,
|
||||
pub owner_user_id: i64,
|
||||
pub owner_tenant_id: String,
|
||||
pub database_path: PathBuf,
|
||||
pub target_id: String,
|
||||
pub issue_type: RegistryIssueType,
|
||||
@@ -27,137 +31,237 @@ pub struct RegistryIssue {
|
||||
pub struct RegistryValidator;
|
||||
|
||||
impl RegistryValidator {
|
||||
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
|
||||
/// and returns a list of detected issues categorized per safety policies.
|
||||
pub fn scan(
|
||||
system_conn: &Connection,
|
||||
_system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
data_dir: &Path,
|
||||
slug_filter: Option<&str>,
|
||||
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
|
||||
use chrono::{DateTime, Utc};
|
||||
let topology = Topology::new(data_dir);
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. Check duplicate slugs (only if not filtering by single slug)
|
||||
if slug_filter.is_none() {
|
||||
let total_count: i64 =
|
||||
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
let distinct_count: i64 = system_conn.query_row(
|
||||
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if total_count != distinct_count {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: 0,
|
||||
database_path: data_dir.join("admin/system.db"),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::DuplicateSlug,
|
||||
description: format!(
|
||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||
total_count, distinct_count
|
||||
),
|
||||
});
|
||||
}
|
||||
let urls_path = topology.global_urls_db();
|
||||
let pages_path = topology.global_landing_pages_db();
|
||||
let reserved_path = topology.reserved_db();
|
||||
|
||||
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_tenant_id: "".to_string(),
|
||||
database_path: urls_path,
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: "One or more v0.8 slug databases are missing from disk".to_string(),
|
||||
});
|
||||
return Ok(issues);
|
||||
}
|
||||
|
||||
// 2. Scan global slugs
|
||||
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||
let urls_conn = Connection::open(&urls_path)?;
|
||||
let pages_conn = Connection::open(&pages_path)?;
|
||||
let reserved_conn = Connection::open(&reserved_path)?;
|
||||
|
||||
// 1. Scan global_urls.db
|
||||
Self::scan_table(
|
||||
&urls_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&pages_conn,
|
||||
&topology,
|
||||
"url",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
// 2. Scan global_landing_pages.db
|
||||
Self::scan_table(
|
||||
&pages_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&topology,
|
||||
"page",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_table(
|
||||
conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
other_conn: &Connection,
|
||||
topology: &Topology,
|
||||
target_type: &str,
|
||||
slug_filter: Option<&str>,
|
||||
issues: &mut Vec<RegistryIssue>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (query, params_vec) = if let Some(slug) = slug_filter {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
|
||||
if target_type == "url" { "url" } else { "landing_page" }
|
||||
),
|
||||
vec![slug.to_string()],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
|
||||
if target_type == "url" {
|
||||
"url"
|
||||
} else {
|
||||
"landing_page"
|
||||
}
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = system_conn.prepare(query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_user_id: i64 = row.get(1)?;
|
||||
let target_type: String = row.get(2)?;
|
||||
let target_id: String = row.get(3)?;
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
let owner_tenant_id_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at_str: String = row.get(3)?;
|
||||
let status: String = row.get(4)?;
|
||||
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
data_dir.join("users").join("1").join("content.db")
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
|
||||
let content_db_path = match tenant_id_res {
|
||||
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
|
||||
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
|
||||
};
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
// 1. Conflict with reserved.db
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_reserved {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: topology.reserved_db(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidTargetType,
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!("Slug '{}' conflicts with a reserved system route", slug),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conflict with the other slug database
|
||||
let other_table = if target_type == "url" {
|
||||
"global_landing_pages"
|
||||
} else {
|
||||
"global_urls"
|
||||
};
|
||||
let in_other: bool = other_conn
|
||||
.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if in_other {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
// 3. Status check
|
||||
if status != "active"
|
||||
&& status != "disabled"
|
||||
&& status != "reserving"
|
||||
&& status != "retired"
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidStatus,
|
||||
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
||||
});
|
||||
}
|
||||
|
||||
// Check owner
|
||||
let owner_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_user_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
// If retired, no active target check is needed
|
||||
if status == "retired" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Owner tenant check in users.db
|
||||
let tid = match tenant_id_res {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path,
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid TenantId '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
|
||||
let owner_exists = match owner_opt {
|
||||
Some(ref u) => u.status != "deleted",
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !owner_exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingOwner,
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
"Slug '{}' references missing or deleted owner tenant '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
// 5. Stale reservation check
|
||||
if status == "reserving" {
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::StaleReservation,
|
||||
@@ -170,19 +274,16 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
// 6. Target record check in tenant content DB
|
||||
if status == "active" || status == "disabled" {
|
||||
if !content_db_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::TrueOrphan,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database does not exist at {:?}",
|
||||
slug, content_db_path
|
||||
@@ -190,47 +291,66 @@ impl RegistryValidator {
|
||||
});
|
||||
} else {
|
||||
match Connection::open(&content_db_path) {
|
||||
Ok(conn) => {
|
||||
Ok(tenant_conn) => {
|
||||
let exists = if target_type == "url" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else if target_type == "page" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTarget,
|
||||
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
|
||||
description: format!(
|
||||
"Slug '{}' (type: '{}', id: '{}') references missing target record in tenant content database",
|
||||
slug, target_type, target_id
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::CorruptDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database is corrupt at {:?}",
|
||||
slug, content_db_path
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database could not be opened: {}",
|
||||
"Slug '{}' owner content database could not be accessed: {}",
|
||||
slug, e
|
||||
),
|
||||
});
|
||||
@@ -240,46 +360,6 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Admin Content Reverse Consistency Check (Legacy DB)
|
||||
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
|
||||
if slug_filter.is_none() {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
|
||||
)?;
|
||||
let mut admin_rows = stmt.query([])?;
|
||||
while let Some(row) = admin_rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let tenant_db_path = data_dir
|
||||
.join("users")
|
||||
.join(id.to_string())
|
||||
.join("content.db");
|
||||
|
||||
if tenant_db_path.exists() {
|
||||
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
|
||||
let url_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let page_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if url_count > 0 || page_count > 0 {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: id,
|
||||
database_path: tenant_db_path.clone(),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
|
||||
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(issues)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user