systemprompt_sync/database/
mod.rs1mod upsert;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sqlx::PgPool;
13use sqlx::prelude::FromRow;
14use systemprompt_identifiers::{ContextId, SessionId, UserId};
15use systemprompt_models::ContextKind;
16
17use crate::error::SyncResult;
18use crate::{SyncDirection, SyncOperationResult};
19
20use upsert::{upsert_context, upsert_user};
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct DatabaseExport {
24 pub users: Vec<UserExport>,
25 pub contexts: Vec<ContextExport>,
26 pub timestamp: DateTime<Utc>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
30pub struct UserExport {
31 pub id: UserId,
32 pub name: String,
33 pub email: String,
34 pub full_name: Option<String>,
35 pub display_name: Option<String>,
36 pub status: String,
37 pub email_verified: bool,
38 pub roles: Vec<String>,
39 pub is_bot: bool,
40 pub is_scanner: bool,
41 pub avatar_url: Option<String>,
42 pub created_at: DateTime<Utc>,
43 pub updated_at: DateTime<Utc>,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
47pub struct ContextExport {
48 pub context_id: ContextId,
49 pub user_id: UserId,
50 pub session_id: Option<SessionId>,
51 pub name: String,
52 pub kind: ContextKind,
53 pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
55}
56
57#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
58pub struct ImportResult {
59 pub created: usize,
60 pub updated: usize,
61 pub skipped: usize,
62}
63
64#[derive(Debug)]
65pub struct DatabaseSyncService {
66 direction: SyncDirection,
67 dry_run: bool,
68 local_database_url: String,
69 cloud_database_url: String,
70}
71
72impl DatabaseSyncService {
73 pub fn new(
74 direction: SyncDirection,
75 dry_run: bool,
76 local_database_url: &str,
77 cloud_database_url: &str,
78 ) -> Self {
79 Self {
80 direction,
81 dry_run,
82 local_database_url: local_database_url.to_owned(),
83 cloud_database_url: cloud_database_url.to_owned(),
84 }
85 }
86
87 pub async fn sync(&self) -> SyncResult<SyncOperationResult> {
88 match self.direction {
89 SyncDirection::Push => self.push().await,
90 SyncDirection::Pull => self.pull().await,
91 }
92 }
93
94 async fn push(&self) -> SyncResult<SyncOperationResult> {
95 let export = export_from_database(&self.local_database_url).await?;
96 let count = export.users.len() + export.contexts.len();
97
98 if self.dry_run {
99 return Ok(SyncOperationResult::dry_run(
100 "database_push",
101 count,
102 serde_json::json!({
103 "users": export.users.len(),
104 "contexts": export.contexts.len(),
105 }),
106 ));
107 }
108
109 import_to_database(&self.cloud_database_url, &export).await?;
110 Ok(SyncOperationResult::success("database_push", count))
111 }
112
113 async fn pull(&self) -> SyncResult<SyncOperationResult> {
114 let export = export_from_database(&self.cloud_database_url).await?;
115 let count = export.users.len() + export.contexts.len();
116
117 if self.dry_run {
118 return Ok(SyncOperationResult::dry_run(
119 "database_pull",
120 count,
121 serde_json::json!({
122 "users": export.users.len(),
123 "contexts": export.contexts.len(),
124 }),
125 ));
126 }
127
128 import_to_database(&self.local_database_url, &export).await?;
129 Ok(SyncOperationResult::success("database_pull", count))
130 }
131}
132
133async fn export_from_database(database_url: &str) -> SyncResult<DatabaseExport> {
134 let pool = PgPool::connect(database_url).await?;
135
136 let users = sqlx::query_as!(
137 UserExport,
138 r#"SELECT id, name, email, full_name, display_name, status, email_verified,
139 roles, is_bot, is_scanner, avatar_url, created_at, updated_at
140 FROM users"#
141 )
142 .fetch_all(&pool)
143 .await?;
144
145 let contexts = sqlx::query_as!(
146 ContextExport,
147 r#"SELECT context_id as "context_id!: ContextId",
148 user_id as "user_id!: UserId",
149 session_id as "session_id: SessionId",
150 name, kind as "kind: ContextKind", created_at, updated_at
151 FROM user_contexts"#
152 )
153 .fetch_all(&pool)
154 .await?;
155
156 Ok(DatabaseExport {
157 users,
158 contexts,
159 timestamp: Utc::now(),
160 })
161}
162
163async fn import_to_database(
164 database_url: &str,
165 export: &DatabaseExport,
166) -> SyncResult<ImportResult> {
167 let pool = PgPool::connect(database_url).await?;
168 let mut created = 0;
169 let mut updated = 0;
170 let mut completed = 0usize;
171 let total = export.users.len() + export.contexts.len();
172
173 for user in &export.users {
174 match upsert_user(&pool, user).await {
175 Ok((c, u)) => {
176 created += c;
177 updated += u;
178 completed += 1;
179 },
180 Err(err) => {
181 return Err(crate::error::SyncError::PartialImport {
182 completed,
183 total,
184 message: format!("user upsert failed: {err}"),
185 });
186 },
187 }
188 }
189
190 for context in &export.contexts {
191 match upsert_context(&pool, context).await {
192 Ok((c, u)) => {
193 created += c;
194 updated += u;
195 completed += 1;
196 },
197 Err(err) => {
198 return Err(crate::error::SyncError::PartialImport {
199 completed,
200 total,
201 message: format!("context upsert failed: {err}"),
202 });
203 },
204 }
205 }
206
207 Ok(ImportResult {
208 created,
209 updated,
210 skipped: 0,
211 })
212}