1use std::collections::HashMap;
2use std::sync::Arc;
3
4use chrono::Utc;
5use rusqlite::params;
6use uuid::Uuid;
7
8use crate::config::settings::SyncConfig;
9use crate::error::{MnemeError, Result};
10use crate::store::db::Database;
11use crate::sync::crdt;
12use crate::sync::peer::{Peer, TransportType};
13use crate::sync::protocol::{
14 ApplyStats, ExportStats, MemoryChangeset, SyncDirection, SyncHello, SyncRequest, SyncResponse,
15 SyncResult, SyncStatus,
16};
17use crate::sync::transport::file::FileTransport;
18use crate::sync::transport::http::HttpTransport;
19
20pub struct SyncEngine {
22 db: Arc<Database>,
23 config: SyncConfig,
24}
25
26impl SyncEngine {
27 pub fn new(db: Arc<Database>, config: SyncConfig) -> Result<Self> {
29 if !config.enabled {
30 return Err(MnemeError::SyncDisabled);
31 }
32 Ok(Self { db, config })
33 }
34
35 pub async fn sync_with_peer(&self, peer: &Peer) -> Result<SyncResult> {
37 let start = std::time::Instant::now();
38 let project = peer.project.clone();
39
40 let result = match peer.transport {
41 TransportType::Http => self.sync_http(peer, &project).await,
42 TransportType::File => self.sync_file(peer, &project).await,
43 TransportType::Ssh => Err(MnemeError::UnsupportedTransport("ssh".to_string())),
44 };
45
46 let duration_ms = start.elapsed().as_millis() as u64;
47
48 match result {
49 Ok(mut r) => {
50 r.duration_ms = duration_ms;
51 let peer_store = self.db.peers();
52 peer_store.update_status(peer.id, "ok")?;
53 Ok(r)
54 }
55 Err(e) => {
56 let peer_store = self.db.peers();
57 let _ = peer_store.update_status(peer.id, "error");
58 Ok(SyncResult {
59 peer_name: peer.name.clone(),
60 direction: SyncDirection::Bidirectional,
61 memories_sent: 0,
62 memories_received: 0,
63 conflicts_resolved: 0,
64 duration_ms,
65 status: SyncStatus::Error,
66 error: Some(e.to_string()),
67 })
68 }
69 }
70 }
71
72 pub async fn sync_auto(&self, project: &str) -> Result<Vec<SyncResult>> {
74 let peers = self.db.peers().list(project)?;
75 let mut results = Vec::new();
76
77 for peer in peers {
78 if peer.auto_sync {
79 match self.sync_with_peer(&peer).await {
80 Ok(r) => results.push(r),
81 Err(e) => {
82 tracing::warn!("auto sync failed for {}: {}", peer.name, e);
83 results.push(SyncResult {
84 peer_name: peer.name.clone(),
85 direction: SyncDirection::Bidirectional,
86 memories_sent: 0,
87 memories_received: 0,
88 conflicts_resolved: 0,
89 duration_ms: 0,
90 status: SyncStatus::Error,
91 error: Some(e.to_string()),
92 });
93 }
94 }
95 }
96 }
97
98 Ok(results)
99 }
100
101 pub fn export_project(
103 &self,
104 project: &str,
105 output: Option<std::path::PathBuf>,
106 ) -> Result<ExportStats> {
107 let conn_arc = self.db.get_conn();
108 let conn = conn_arc
109 .lock()
110 .map_err(|_| MnemeError::Config("mutex poisoned".into()))?;
111
112 let mut stmt = conn.prepare(
113 "SELECT automerge_id, doc_bytes FROM sync_state
114 JOIN memories ON sync_state.memory_id = memories.id
115 WHERE memories.project = ?1 AND sync_state.is_tombstone = 0",
116 )?;
117
118 let rows = stmt.query_map(params![project], |row| {
119 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
120 })?;
121
122 let mut changes = Vec::new();
123 for row in rows {
124 let (automerge_id, doc_bytes) = row?;
125 changes.push(MemoryChangeset {
126 automerge_id,
127 payload: doc_bytes,
128 is_full_doc: true,
129 });
130 }
131
132 let dir = output.unwrap_or_else(|| {
133 let mut path =
134 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
135 path.push("sync_exports");
136 path
137 });
138
139 let transport = FileTransport::new(dir)?;
140 let (_, stats) = transport.export(project, &changes)?;
141
142 tracing::info!(
143 "exported project {}: {} memories",
144 project,
145 stats.memories_exported
146 );
147 Ok(ExportStats {
148 memories_exported: stats.memories_exported,
149 bytes_written: stats.bytes_written,
150 })
151 }
152
153 pub fn build_hello(&self, project: &str) -> Result<SyncHello> {
155 let conn_arc = self.db.get_conn();
156 let conn = conn_arc
157 .lock()
158 .map_err(|_| MnemeError::Config("mutex poisoned".into()))?;
159
160 let count: u32 = conn.query_row(
161 "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
162 params![project],
163 |row| row.get(0),
164 )?;
165
166 let mut heads = HashMap::new();
167 let mut stmt = conn.prepare(
168 "SELECT automerge_id, doc_bytes FROM sync_state
169 JOIN memories ON sync_state.memory_id = memories.id
170 WHERE memories.project = ?1",
171 )?;
172 let rows = stmt.query_map(params![project], |row| {
173 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
174 })?;
175
176 for row in rows {
177 let (automerge_id, doc_bytes) = row?;
178 match crdt::get_heads(&doc_bytes) {
179 Ok(h) => {
180 heads.insert(automerge_id, h);
181 }
182 Err(e) => {
183 tracing::warn!("failed to get heads: {}", e);
184 }
185 }
186 }
187
188 let peer_id = if self.config.peer_id.is_empty() {
189 Uuid::new_v4()
190 } else {
191 Uuid::parse_str(&self.config.peer_id).unwrap_or_else(|_| Uuid::new_v4())
192 };
193
194 Ok(SyncHello {
195 peer_id,
196 peer_name: self.config.peer_name.clone(),
197 project: project.to_string(),
198 mneme_version: env!("CARGO_PKG_VERSION").to_string(),
199 memory_count: count,
200 heads,
201 })
202 }
203
204 pub fn apply_response(&self, response: &SyncResponse) -> Result<ApplyStats> {
206 let mut applied = 0u32;
207 let mut conflicts = 0u32;
208
209 for change in &response.changes {
210 if let Err(e) = self.apply_changeset(change) {
211 tracing::warn!("failed to apply changeset: {}", e);
212 conflicts += 1;
213 } else {
214 applied += 1;
215 }
216 }
217
218 Ok(ApplyStats {
219 memories_applied: applied,
220 conflicts_resolved: conflicts,
221 })
222 }
223
224 pub fn build_response(&self, request: &SyncRequest) -> Result<SyncResponse> {
226 Ok(SyncResponse {
227 project: request.project.clone(),
228 changes: Vec::new(),
229 tombstones: Vec::new(),
230 })
231 }
232
233 async fn sync_http(&self, peer: &Peer, project: &str) -> Result<SyncResult> {
234 let transport = HttpTransport::new(peer.address.clone())?;
235 let hello = self.build_hello(project)?;
236 let _remote_hello = transport.hello(&hello).await?;
237
238 let request = SyncRequest {
239 project: project.to_string(),
240 have: hello.heads,
241 };
242
243 let response = transport.pull(&request).await?;
244 let apply_stats = self.apply_response(&response)?;
245
246 let push_response = self.build_response(&request)?;
247 transport.push(&push_response).await?;
248
249 Ok(SyncResult {
250 peer_name: peer.name.clone(),
251 direction: SyncDirection::Bidirectional,
252 memories_sent: push_response.changes.len() as u32,
253 memories_received: response.changes.len() as u32,
254 conflicts_resolved: apply_stats.conflicts_resolved,
255 duration_ms: 0,
256 status: SyncStatus::Ok,
257 error: None,
258 })
259 }
260
261 async fn sync_file(&self, peer: &Peer, project: &str) -> Result<SyncResult> {
262 let dir = std::path::PathBuf::from(&peer.address);
263 let transport = FileTransport::new(dir)?;
264 let (changes, _) = transport.import_pending(project)?;
265
266 let mut applied = 0u32;
267 let mut conflicts = 0u32;
268
269 for change in changes {
270 if let Err(e) = self.apply_changeset(&change) {
271 tracing::warn!("failed to apply file changeset: {}", e);
272 conflicts += 1;
273 } else {
274 applied += 1;
275 }
276 }
277
278 Ok(SyncResult {
279 peer_name: peer.name.clone(),
280 direction: SyncDirection::Pull,
281 memories_sent: 0,
282 memories_received: applied + conflicts,
283 conflicts_resolved: conflicts,
284 duration_ms: 0,
285 status: if conflicts > 0 {
286 SyncStatus::Partial
287 } else {
288 SyncStatus::Ok
289 },
290 error: None,
291 })
292 }
293
294 fn apply_changeset(&self, change: &MemoryChangeset) -> Result<()> {
295 let conn_arc = self.db.get_conn();
296 let conn = conn_arc
297 .lock()
298 .map_err(|_| MnemeError::Config("mutex poisoned".into()))?;
299
300 if change.is_full_doc {
301 let memory = crdt::doc_to_memory(&change.payload)?;
302 let exists: bool = conn
303 .query_row(
304 "SELECT 1 FROM sync_state WHERE automerge_id = ?1",
305 params![&change.automerge_id],
306 |_| Ok(true),
307 )
308 .unwrap_or(false);
309
310 if exists {
311 let existing_bytes: Vec<u8> = conn.query_row(
312 "SELECT doc_bytes FROM sync_state WHERE automerge_id = ?1",
313 params![&change.automerge_id],
314 |row| row.get(0),
315 )?;
316 let merged = crdt::merge_docs(&existing_bytes, &change.payload)?;
317 conn.execute(
318 "UPDATE sync_state SET doc_bytes = ?1 WHERE automerge_id = ?2",
319 params![merged, &change.automerge_id],
320 )?;
321 } else {
322 conn.execute(
323 "INSERT INTO sync_state (memory_id, automerge_id, doc_bytes, last_synced, is_tombstone)
324 VALUES (?1, ?2, ?3, ?4, 0)
325 ON CONFLICT(automerge_id) DO UPDATE SET
326 doc_bytes = excluded.doc_bytes,
327 last_synced = excluded.last_synced",
328 params![
329 memory.id.to_string(),
330 &change.automerge_id,
331 &change.payload,
332 Utc::now().to_rfc3339(),
333 ],
334 )?;
335 }
336 }
337
338 Ok(())
339 }
340
341 pub fn ensure_sync_state(&self, memory_id: Uuid, automerge_id: &str) -> Result<()> {
343 let conn_arc = self.db.get_conn();
344 let conn = conn_arc
345 .lock()
346 .map_err(|_| MnemeError::Config("mutex poisoned".into()))?;
347
348 let exists: bool = conn
349 .query_row(
350 "SELECT 1 FROM sync_state WHERE memory_id = ?1",
351 params![memory_id.to_string()],
352 |_| Ok(true),
353 )
354 .unwrap_or(false);
355
356 if !exists {
357 let memory = match self.db.memories().get(memory_id)? {
358 Some(m) => m,
359 None => return Err(MnemeError::NotFound(memory_id)),
360 };
361
362 let mut doc = crdt::memory_to_doc(&memory)?;
363 let doc_bytes = crdt::doc_to_bytes(&mut doc)?;
364
365 conn.execute(
366 "INSERT INTO sync_state (memory_id, automerge_id, doc_bytes, last_synced, is_tombstone)
367 VALUES (?1, ?2, ?3, ?4, 0)
368 ON CONFLICT(memory_id) DO UPDATE SET
369 automerge_id = excluded.automerge_id,
370 doc_bytes = excluded.doc_bytes,
371 last_synced = excluded.last_synced",
372 params![
373 memory_id.to_string(),
374 automerge_id,
375 doc_bytes,
376 Utc::now().to_rfc3339(),
377 ],
378 )?;
379 }
380
381 Ok(())
382 }
383}