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