systemprompt_users/repository/user/
merge.rs1use sqlx::{Acquire, Postgres, Transaction};
7use systemprompt_identifiers::{ContextId, SessionId, UserId};
8
9use crate::error::Result;
10use crate::repository::UserRepository;
11
12const MERGE_TOOL_NAME: &str = "users.merge";
13const MERGE_POLICY: &str = "account_merge";
14
15#[derive(Debug, Clone, Copy)]
16pub struct MergeResult {
17 pub sessions: u64,
18 pub tasks: u64,
19 pub total_rows: u64,
20}
21
22pub const MERGE_EXCLUDED_SECURITY_TABLES: &[&str] = &[
23 "oauth_auth_codes",
24 "oauth_refresh_tokens",
25 "oauth_clients",
26 "webauthn_credentials",
27 "webauthn_challenges",
28 "webauthn_setup_tokens",
29 "user_api_keys",
30 "user_device_certs",
31 "bridge_sessions",
32 "bridge_exchange_codes",
33 "federated_identities",
34];
35
36impl UserRepository {
37 pub async fn merge_users(&self, source_id: &UserId, target_id: &UserId) -> Result<MergeResult> {
38 let mut conn = self.write_pool.acquire().await?;
39 let mut tx = conn.begin().await?;
40
41 let source = source_id.as_str();
42 let target = target_id.as_str();
43
44 let sessions = transfer_sessions(&mut tx, source, target).await?;
45 let tasks = transfer_tasks(&mut tx, source, target).await?;
46 let mut total_rows = sessions + tasks;
47 total_rows += transfer_audit_rows(&mut tx, source, target).await?;
48 total_rows += transfer_content_rows(&mut tx, source, target).await?;
49 record_merge_attribution(&mut tx, source, target).await?;
50
51 sqlx::query!(
52 "UPDATE fingerprint_reputation SET associated_user_ids = \
53 array_replace(associated_user_ids, $2, $1) WHERE $2 = ANY(associated_user_ids)",
54 target,
55 source
56 )
57 .execute(&mut *tx)
58 .await?;
59
60 sqlx::query!(
61 "DELETE FROM ai_quota_buckets WHERE subject_kind = 'user' AND subject_id = $1",
62 source
63 )
64 .execute(&mut *tx)
65 .await?;
66
67 sqlx::query!("DELETE FROM users WHERE id = $1", source)
68 .execute(&mut *tx)
69 .await?;
70
71 tx.commit().await?;
72 Ok(MergeResult {
73 sessions,
74 tasks,
75 total_rows,
76 })
77 }
78}
79
80async fn transfer_sessions(
81 tx: &mut Transaction<'_, Postgres>,
82 source: &str,
83 target: &str,
84) -> Result<u64> {
85 let result = sqlx::query!(
86 "UPDATE user_sessions SET user_id = $1 WHERE user_id = $2",
87 target,
88 source
89 )
90 .execute(&mut **tx)
91 .await?;
92 Ok(result.rows_affected())
93}
94
95async fn transfer_tasks(
96 tx: &mut Transaction<'_, Postgres>,
97 source: &str,
98 target: &str,
99) -> Result<u64> {
100 let result = sqlx::query!(
101 "UPDATE agent_tasks SET user_id = $1 WHERE user_id = $2",
102 target,
103 source
104 )
105 .execute(&mut **tx)
106 .await?;
107 Ok(result.rows_affected())
108}
109
110async fn record_merge_attribution(
115 tx: &mut Transaction<'_, Postgres>,
116 source: &str,
117 target: &str,
118) -> Result<()> {
119 let id = uuid::Uuid::new_v4().to_string();
120 let context_id = ContextId::derived_from_session(&SessionId::new(id.clone()));
121 sqlx::query!(
122 "INSERT INTO governance_decisions (id, user_id, session_id, tool_name, decision, policy, \
123 reason, actor_kind, actor_id, context_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, \
124 $10)",
125 id,
126 target,
127 id,
128 MERGE_TOOL_NAME,
129 "allow",
130 MERGE_POLICY,
131 format!("account merge: {source} merged into {target}"),
132 "system",
133 target,
134 context_id.as_str(),
135 )
136 .execute(&mut **tx)
137 .await?;
138 Ok(())
139}
140
141async fn transfer_audit_rows(
142 tx: &mut Transaction<'_, Postgres>,
143 source: &str,
144 target: &str,
145) -> Result<u64> {
146 let mut moved = 0;
147 moved += sqlx::query!(
148 "UPDATE task_messages SET user_id = $1 WHERE user_id = $2",
149 target,
150 source
151 )
152 .execute(&mut **tx)
153 .await?
154 .rows_affected();
155 moved += sqlx::query!(
156 "UPDATE user_contexts SET user_id = $1 WHERE user_id = $2",
157 target,
158 source
159 )
160 .execute(&mut **tx)
161 .await?
162 .rows_affected();
163 moved += sqlx::query!(
164 "UPDATE mcp_tool_executions SET user_id = $1 WHERE user_id = $2",
165 target,
166 source
167 )
168 .execute(&mut **tx)
169 .await?
170 .rows_affected();
171 moved += sqlx::query!(
172 "UPDATE mcp_artifacts SET user_id = $1 WHERE user_id = $2",
173 target,
174 source
175 )
176 .execute(&mut **tx)
177 .await?
178 .rows_affected();
179 moved += sqlx::query!(
180 "UPDATE mcp_sessions SET user_id = $1 WHERE user_id = $2",
181 target,
182 source
183 )
184 .execute(&mut **tx)
185 .await?
186 .rows_affected();
187 moved += sqlx::query!(
188 "UPDATE logs SET user_id = $1 WHERE user_id = $2",
189 target,
190 source
191 )
192 .execute(&mut **tx)
193 .await?
194 .rows_affected();
195 Ok(moved)
196}
197
198async fn transfer_content_rows(
199 tx: &mut Transaction<'_, Postgres>,
200 source: &str,
201 target: &str,
202) -> Result<u64> {
203 let mut moved = 0;
204 moved += sqlx::query!(
205 "UPDATE ai_requests SET user_id = $1 WHERE user_id = $2",
206 target,
207 source
208 )
209 .execute(&mut **tx)
210 .await?
211 .rows_affected();
212 moved += sqlx::query!(
213 "UPDATE engagement_events SET user_id = $1 WHERE user_id = $2",
214 target,
215 source
216 )
217 .execute(&mut **tx)
218 .await?
219 .rows_affected();
220 moved += sqlx::query!(
221 "UPDATE analytics_events SET user_id = $1 WHERE user_id = $2",
222 target,
223 source
224 )
225 .execute(&mut **tx)
226 .await?
227 .rows_affected();
228 moved += sqlx::query!(
229 "UPDATE event_outbox SET user_id = $1 WHERE user_id = $2",
230 target,
231 source
232 )
233 .execute(&mut **tx)
234 .await?
235 .rows_affected();
236 moved += sqlx::query!(
237 "UPDATE files SET user_id = $1 WHERE user_id = $2",
238 target,
239 source
240 )
241 .execute(&mut **tx)
242 .await?
243 .rows_affected();
244 moved += sqlx::query!(
245 "UPDATE link_clicks SET user_id = $1 WHERE user_id = $2",
246 target,
247 source
248 )
249 .execute(&mut **tx)
250 .await?
251 .rows_affected();
252 Ok(moved)
253}