use sqlx::{AssertSqlSafe, Postgres, Transaction};
use systemprompt_database::admin::SafeIdentifier;
use systemprompt_extension::purge::{
OrphanSweep, UserPurgeTable, registered_orphan_sweeps, registered_user_purge_tables,
};
use systemprompt_extension::{orphan_sweeps, user_purge_tables};
use systemprompt_identifiers::UserId;
use crate::error::{Result, UserError};
use crate::repository::UserRepository;
user_purge_tables!(
"systemprompt-core",
[
("ai_requests", "user_id"),
("mcp_artifacts", "user_id"),
("mcp_tool_executions", "user_id"),
("mcp_sessions", "user_id"),
("mcp_external_sessions", "user_id"),
("mcp_proxy_identities", "user_id"),
("governance_decisions", "user_id"),
("agent_tasks", "user_id"),
("task_messages", "user_id"),
("files", "user_id"),
("event_outbox", "user_id"),
("logs", "user_id"),
("user_rate_limit_buckets", "user_id"),
("oauth_jti_revocations", "user_id"),
]
);
orphan_sweeps!(
"systemprompt-core",
[{
table: "artifact_payloads",
key: "sha256",
referenced_by: "mcp_artifacts",
via: "payload_sha256",
user_column: "user_id",
}]
);
#[derive(Debug, Clone, Copy)]
pub struct PurgeCount {
pub owner: &'static str,
pub table: &'static str,
pub rows: i64,
}
fn identifier(kind: &str, raw: &str) -> Result<SafeIdentifier> {
SafeIdentifier::parse(raw)
.map_err(|e| UserError::Validation(format!("purge {kind} {raw}: {e}")))
}
fn predicate(entry: &UserPurgeTable) -> Result<String> {
let Some(raw) = entry.predicate else {
return Ok(String::new());
};
let allowed = |c: char| c.is_ascii_alphanumeric() || " _'<>=!.()".contains(c);
if raw.is_empty() || raw.contains("''") || !raw.chars().all(allowed) {
return Err(UserError::Validation(format!(
"purge predicate for {}: not a plain comparison: {raw}",
entry.table
)));
}
Ok(format!(" AND ({raw})"))
}
fn purge_where(entry: &UserPurgeTable) -> Result<String> {
let table = identifier("table", entry.table)?;
let column = identifier("column", entry.column)?;
Ok(format!(
"FROM {} WHERE {}::text = $1{}",
table.quoted(),
column.quoted(),
predicate(entry)?
))
}
struct SweepSql {
delete: String,
preview: String,
}
fn sweep_sql(sweep: &OrphanSweep) -> Result<SweepSql> {
let table = identifier("sweep table", sweep.table)?.quoted();
let key = identifier("sweep key", sweep.key)?.quoted();
let referenced_by = identifier("sweep referrer", sweep.referenced_by)?.quoted();
let via = identifier("sweep reference", sweep.via)?.quoted();
let user = identifier("sweep user column", sweep.user_column)?.quoted();
let referrer = format!("SELECT 1 FROM {referenced_by} r WHERE r.{via} = t.{key}");
Ok(SweepSql {
delete: format!("DELETE FROM {table} t WHERE NOT EXISTS ({referrer})"),
preview: format!(
"SELECT COUNT(*) FROM {table} t \
WHERE EXISTS ({referrer} AND r.{user}::text = $1) \
AND NOT EXISTS ({referrer} AND r.{user}::text IS DISTINCT FROM $1)"
),
})
}
impl UserRepository {
pub(super) async fn purge_user_rows(
tx: &mut Transaction<'_, Postgres>,
id: &UserId,
) -> Result<Vec<PurgeCount>> {
let mut removed = Vec::new();
for entry in registered_user_purge_tables() {
let sql = format!("DELETE {}", purge_where(entry)?);
let result = sqlx::query(AssertSqlSafe(sql))
.bind(id.as_str())
.execute(&mut **tx)
.await?;
removed.push(PurgeCount {
owner: entry.owner,
table: entry.table,
rows: i64::try_from(result.rows_affected()).unwrap_or(i64::MAX),
});
}
for sweep in registered_orphan_sweeps() {
let result = sqlx::query(AssertSqlSafe(sweep_sql(sweep)?.delete))
.execute(&mut **tx)
.await?;
removed.push(PurgeCount {
owner: sweep.owner,
table: sweep.table,
rows: i64::try_from(result.rows_affected()).unwrap_or(i64::MAX),
});
}
Ok(removed)
}
pub async fn purge_preview(&self, id: &UserId) -> Result<Vec<PurgeCount>> {
let mut counts = Vec::new();
let sessions: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM user_sessions WHERE user_id = $1"#,
id.as_str()
)
.fetch_one(&*self.pool)
.await?;
counts.push(PurgeCount {
owner: "systemprompt-core",
table: "user_sessions",
rows: sessions,
});
for entry in registered_user_purge_tables() {
let sql = format!("SELECT COUNT(*) {}", purge_where(entry)?);
let rows: i64 = sqlx::query_scalar(AssertSqlSafe(sql))
.bind(id.as_str())
.fetch_one(&*self.pool)
.await?;
counts.push(PurgeCount {
owner: entry.owner,
table: entry.table,
rows,
});
}
for sweep in registered_orphan_sweeps() {
let rows: i64 = sqlx::query_scalar(AssertSqlSafe(sweep_sql(sweep)?.preview))
.bind(id.as_str())
.fetch_one(&*self.pool)
.await?;
counts.push(PurgeCount {
owner: sweep.owner,
table: sweep.table,
rows,
});
}
Ok(counts)
}
}