systemprompt_users/repository/user/
purge.rs1use sqlx::{AssertSqlSafe, Postgres, Transaction};
21use systemprompt_database::admin::SafeIdentifier;
22use systemprompt_extension::purge::{
23 OrphanSweep, UserPurgeTable, registered_orphan_sweeps, registered_user_purge_tables,
24};
25use systemprompt_extension::{orphan_sweeps, user_purge_tables};
26use systemprompt_identifiers::UserId;
27
28use crate::error::{Result, UserError};
29use crate::repository::UserRepository;
30
31user_purge_tables!(
36 "systemprompt-core",
37 [
38 ("ai_requests", "user_id"),
39 ("mcp_artifacts", "user_id"),
40 ("mcp_tool_executions", "user_id"),
41 ("mcp_sessions", "user_id"),
42 ("mcp_external_sessions", "user_id"),
43 ("mcp_proxy_identities", "user_id"),
44 ("governance_decisions", "user_id"),
45 ("agent_tasks", "user_id"),
46 ("task_messages", "user_id"),
47 ("files", "user_id"),
48 ("event_outbox", "user_id"),
49 ("logs", "user_id"),
50 ("user_rate_limit_buckets", "user_id"),
51 ("oauth_jti_revocations", "user_id"),
52 ("sso_login_events", "user_id"),
53 ]
54);
55
56orphan_sweeps!(
57 "systemprompt-core",
58 [{
59 table: "artifact_payloads",
60 key: "sha256",
61 referenced_by: "mcp_artifacts",
62 via: "payload_sha256",
63 user_column: "user_id",
64 }]
65);
66
67#[derive(Debug, Clone, Copy)]
69pub struct PurgeCount {
70 pub owner: &'static str,
71 pub table: &'static str,
72 pub rows: i64,
73}
74
75fn identifier(kind: &'static str, raw: &str) -> Result<SafeIdentifier> {
76 SafeIdentifier::parse(raw).map_err(|source| UserError::PurgeIdentifier {
77 kind,
78 name: raw.to_owned(),
79 source,
80 })
81}
82
83fn predicate(entry: &UserPurgeTable) -> Result<String> {
87 let Some(raw) = entry.predicate else {
88 return Ok(String::new());
89 };
90 let allowed = |c: char| c.is_ascii_alphanumeric() || " _'<>=!.()".contains(c);
91 if raw.is_empty() || raw.contains("''") || !raw.chars().all(allowed) {
92 return Err(UserError::Validation(format!(
93 "purge predicate for {}: not a plain comparison: {raw}",
94 entry.table
95 )));
96 }
97 Ok(format!(" AND ({raw})"))
98}
99
100fn purge_where(entry: &UserPurgeTable) -> Result<String> {
101 let table = identifier("table", entry.table)?;
102 let column = identifier("column", entry.column)?;
103 Ok(format!(
106 "FROM {} WHERE {}::text = $1{}",
107 table.quoted(),
108 column.quoted(),
109 predicate(entry)?
110 ))
111}
112
113struct SweepSql {
114 candidates: String,
115 delete: String,
116 preview: String,
117}
118
119fn sweep_sql(sweep: &OrphanSweep) -> Result<SweepSql> {
120 let table = identifier("sweep table", sweep.table)?.quoted();
121 let key = identifier("sweep key", sweep.key)?.quoted();
122 let referenced_by = identifier("sweep referrer", sweep.referenced_by)?.quoted();
123 let via = identifier("sweep reference", sweep.via)?.quoted();
124 let user = identifier("sweep user column", sweep.user_column)?.quoted();
125 let referrer = format!("SELECT 1 FROM {referenced_by} r WHERE r.{via} = t.{key}");
126 Ok(SweepSql {
127 candidates: format!(
128 "SELECT DISTINCT r.{via}::text FROM {referenced_by} r \
129 WHERE r.{user}::text = $1 AND r.{via} IS NOT NULL"
130 ),
131 delete: format!(
132 "DELETE FROM {table} t WHERE t.{key}::text = ANY($1) AND NOT EXISTS ({referrer})"
133 ),
134 preview: format!(
135 "SELECT COUNT(*) FROM {table} t \
136 WHERE EXISTS ({referrer} AND r.{user}::text = $1) \
137 AND NOT EXISTS ({referrer} AND r.{user}::text IS DISTINCT FROM $1)"
138 ),
139 })
140}
141
142impl UserRepository {
143 pub(super) async fn purge_user_rows(
144 tx: &mut Transaction<'_, Postgres>,
145 id: &UserId,
146 ) -> Result<Vec<PurgeCount>> {
147 let mut sweeps = Vec::new();
148 for sweep in registered_orphan_sweeps() {
149 let sql = sweep_sql(sweep)?;
150 let keys: Vec<String> = sqlx::query_scalar(AssertSqlSafe(sql.candidates))
151 .bind(id.as_str())
152 .fetch_all(&mut **tx)
153 .await?;
154 sweeps.push((sweep, sql.delete, keys));
155 }
156 let mut removed = Vec::new();
157 for entry in registered_user_purge_tables() {
158 let sql = format!("DELETE {}", purge_where(entry)?);
159 let result = sqlx::query(AssertSqlSafe(sql))
160 .bind(id.as_str())
161 .execute(&mut **tx)
162 .await?;
163 removed.push(PurgeCount {
164 owner: entry.owner,
165 table: entry.table,
166 rows: i64::try_from(result.rows_affected()).unwrap_or(i64::MAX),
167 });
168 }
169 for (sweep, delete, keys) in sweeps {
170 let result = sqlx::query(AssertSqlSafe(delete))
171 .bind(keys)
172 .execute(&mut **tx)
173 .await?;
174 removed.push(PurgeCount {
175 owner: sweep.owner,
176 table: sweep.table,
177 rows: i64::try_from(result.rows_affected()).unwrap_or(i64::MAX),
178 });
179 }
180 Ok(removed)
181 }
182
183 pub async fn purge_preview(&self, id: &UserId) -> Result<Vec<PurgeCount>> {
184 let mut counts = Vec::new();
185 let sessions: i64 = sqlx::query_scalar!(
186 r#"SELECT COUNT(*) AS "count!" FROM user_sessions WHERE user_id = $1"#,
187 id.as_str()
188 )
189 .fetch_one(&*self.pool)
190 .await?;
191 counts.push(PurgeCount {
192 owner: "systemprompt-core",
193 table: "user_sessions",
194 rows: sessions,
195 });
196 for entry in registered_user_purge_tables() {
197 let sql = format!("SELECT COUNT(*) {}", purge_where(entry)?);
198 let rows: i64 = sqlx::query_scalar(AssertSqlSafe(sql))
199 .bind(id.as_str())
200 .fetch_one(&*self.pool)
201 .await?;
202 counts.push(PurgeCount {
203 owner: entry.owner,
204 table: entry.table,
205 rows,
206 });
207 }
208 for sweep in registered_orphan_sweeps() {
209 let rows: i64 = sqlx::query_scalar(AssertSqlSafe(sweep_sql(sweep)?.preview))
210 .bind(id.as_str())
211 .fetch_one(&*self.pool)
212 .await?;
213 counts.push(PurgeCount {
214 owner: sweep.owner,
215 table: sweep.table,
216 rows,
217 });
218 }
219 Ok(counts)
220 }
221}