1use crate::{AppState, emit_durable_http};
12use anyhow::{Context, Result};
13use std::path::PathBuf;
14use std::sync::Arc;
15
16pub const SESSION_CAP: u64 = 500;
17pub const GLOBAL_CAP: u64 = 2000;
18
19#[derive(Debug, Default, Clone, Copy)]
20pub struct SyncStats {
21 pub messages: u64,
22 pub parts: u64,
23 pub sessions: u64,
24 pub backlog: bool,
26}
27
28pub fn sync_enabled() -> bool {
29 std::env::var("OCSERVE_LEGACY_SYNC")
30 .map(|v| v != "0")
31 .unwrap_or(true)
32}
33
34pub fn legacy_db_path() -> PathBuf {
35 if let Ok(p) = std::env::var("OCSERVE_LEGACY_DB") {
36 return PathBuf::from(p);
37 }
38 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
39 PathBuf::from(home).join(".local/share/opencode/opencode.db")
40}
41
42pub fn sync_tick(st: &Arc<AppState>) -> Result<SyncStats> {
45 sync_tick_at(st, &legacy_db_path())
46}
47
48pub fn sync_tick_at(st: &Arc<AppState>, legacy_path: &std::path::Path) -> Result<SyncStats> {
50 if !legacy_path.exists() {
51 anyhow::bail!("legacy db not found at {}", legacy_path.display());
52 }
53 let legacy = ocserve_store::open_legacy_ro(legacy_path)?;
54 let ocserve_uri = format!(
57 "file:{}?mode=ro",
58 st.db.to_str().unwrap_or_default().replace('?', "%3f")
59 );
60 legacy
61 .execute("ATTACH DATABASE ? AS ocserve_ro", [&ocserve_uri])
62 .context("attach ocserve for hole check")?;
63
64 let ocserve_ids: Vec<String> = {
66 let conn = ocserve_store::pragma::open_reader(&st.db)?;
67 let mut stmt = conn.prepare("SELECT id FROM session ORDER BY id")?;
68 stmt.query_map([], |r| r.get::<_, String>(0))?
69 .filter_map(|r| r.ok())
70 .collect::<Vec<_>>()
71 };
72 if ocserve_ids.is_empty() {
73 return Ok(SyncStats::default());
74 }
75 let ph = (1..=ocserve_ids.len())
76 .map(|i| format!("?{i}"))
77 .collect::<Vec<_>>()
78 .join(",");
79 let mut legacy_sessions: std::collections::HashMap<String, (String, i64)> =
80 std::collections::HashMap::new();
81 {
82 let mut stmt = legacy.prepare(&format!(
83 "SELECT id, title, time_updated FROM session WHERE id IN ({ph})"
84 ))?;
85 let rows = stmt.query_map(rusqlite::params_from_iter(ocserve_ids.iter()), |r| {
86 Ok((
87 r.get::<_, String>(0)?,
88 r.get::<_, String>(1)?,
89 r.get::<_, i64>(2)?,
90 ))
91 })?;
92 for row in rows.flatten() {
93 legacy_sessions.insert(row.0, (row.1, row.2));
94 }
95 }
96
97 let now_ms = std::time::SystemTime::now()
98 .duration_since(std::time::UNIX_EPOCH)
99 .map(|d| d.as_millis() as i64)
100 .unwrap_or(0);
101
102 let mut to_adopt: Vec<(String, Option<String>)> = Vec::new();
105 for sid in legacy_sessions.keys() {
106 let conn = ocserve_store::pragma::open_reader(&st.db)?;
107 let exists: i64 = conn.query_row(
108 "SELECT count(*) FROM import_sync WHERE session_id = ?1",
109 [sid.as_str()],
110 |r| r.get(0),
111 )?;
112 if exists > 0 {
113 continue;
114 }
115 let newest: Option<(String, i64)> = {
116 let mut stmt =
117 conn.prepare("SELECT id, time_created FROM msg WHERE session_id = ?1 ORDER BY time_created DESC, id DESC LIMIT 1")?;
118 stmt.query_row([sid.as_str()], |r| Ok((r.get(0)?, r.get(1)?)))
119 .ok()
120 };
121 let cursor = newest.map(|(id, t)| ocserve_store::encode_cursor(&id, t));
122 to_adopt.push((sid.clone(), cursor));
123 }
124 if !to_adopt.is_empty() {
125 let mut ops = Vec::new();
126 for (sid, cursor) in &to_adopt {
127 ops.push(ocserve_store::WriteOp::Sql {
128 sql: "INSERT OR IGNORE INTO import_sync (session_id, cursor, last_sync_ms) VALUES (?1, ?2, ?3)".into(),
129 params: vec![sid.clone().into(), cursor.clone().into(), now_ms.into()],
130 });
131 }
132 st.writer.write(ops)?;
133 }
134
135 let rows: Vec<(String, Option<String>)> = {
137 let conn = ocserve_store::pragma::open_reader(&st.db)?;
138 let mut stmt =
139 conn.prepare("SELECT session_id, cursor FROM import_sync ORDER BY session_id")?;
140 stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
141 .filter_map(|r| r.ok())
142 .collect()
143 };
144
145 let mut stats = SyncStats::default();
146 let mut budget = GLOBAL_CAP;
147 'sessions: for (sid, cursor_txt) in rows {
148 if budget == 0 {
149 stats.backlog = true;
150 break;
151 }
152 let mut cursor_owned = cursor_txt
153 .as_deref()
154 .map(ocserve_store::decode_cursor)
155 .transpose()?;
156 let oldest_missing: Option<(String, i64)> = legacy
169 .query_row(
170 "SELECT m.id, m.time_created FROM message m \
171 WHERE m.session_id = ?1 AND NOT EXISTS (\
172 SELECT 1 FROM ocserve_ro.msg r WHERE r.id = m.id) \
173 ORDER BY m.time_created, m.id LIMIT 1",
174 [&sid],
175 |r| Ok((r.get(0)?, r.get(1)?)),
176 )
177 .ok();
178 if let Some((mid, mt)) = oldest_missing {
179 let pred: Option<(String, i64)> = legacy
180 .query_row(
181 "SELECT id, time_created FROM message \
182 WHERE session_id = ?1 AND (time_created < ?2 OR (time_created = ?2 AND id < ?3)) \
183 ORDER BY time_created DESC, id DESC LIMIT 1",
184 rusqlite::params![sid, mt, mid],
185 |r| Ok((r.get(0)?, r.get(1)?)),
186 )
187 .ok();
188 match pred {
189 Some(pred) => {
190 let ahead = cursor_owned.as_ref().is_none_or(|(cid, ct)| {
191 *ct > pred.1 || (*ct == pred.1 && cid.as_str() > pred.0.as_str())
192 });
193 if ahead {
194 tracing::info!(
195 "sync cursor rewound for hole session={sid}: -> t{} (missing {mid})",
196 pred.1
197 );
198 cursor_owned = Some(pred);
199 }
200 }
201 None => {
202 if cursor_owned.is_some() {
204 tracing::info!("sync cursor rewound to start session={sid}");
205 cursor_owned = None;
206 }
207 }
208 }
209 }
210 let cursor = cursor_owned.as_ref().map(|(id, t)| (id.as_str(), *t));
211 let cap = SESSION_CAP.min(budget);
212 let (msgs, has_more) = ocserve_store::pull_legacy_delta(&legacy, &sid, cursor, cap)?;
213 if msgs.is_empty() {
214 continue;
215 }
216 stats.sessions += 1;
217 if has_more {
218 stats.backlog = true;
219 }
220
221 let existing_ids: std::collections::HashSet<String> = {
223 let conn = ocserve_store::pragma::open_reader(&st.db)?;
224 let mut set = std::collections::HashSet::new();
225 for chunk in msgs.chunks(100) {
226 let ph = (1..=chunk.len())
227 .map(|i| format!("?{i}"))
228 .collect::<Vec<_>>()
229 .join(",");
230 let mut stmt = conn.prepare(&format!("SELECT id FROM msg WHERE id IN ({ph})"))?;
231 let found = stmt
232 .query_map(
233 rusqlite::params_from_iter(chunk.iter().map(|m| m.id.as_str())),
234 |r| r.get::<_, String>(0),
235 )?
236 .filter_map(|r| r.ok())
237 .collect::<std::collections::HashSet<_>>();
238 for id in found {
239 set.insert(id);
240 }
241 }
242 set
243 };
244
245 let mut seq: i64 = {
246 let conn = ocserve_store::pragma::open_reader(&st.db)?;
247 conn.query_row(
248 "SELECT COALESCE(MAX(seq),0) FROM msg WHERE session_id = ?1",
249 [&sid],
250 |r| r.get(0),
251 )?
252 };
253
254 let mut last: Option<(String, i64)> = None;
255 for m in &msgs {
256 if budget == 0 {
257 stats.backlog = true;
258 break 'sessions;
259 }
260 budget -= 1;
261 last = Some((m.id.clone(), m.time_created));
262 if existing_ids.contains(&m.id) {
263 continue; }
265 seq += 1;
266 let data_v: serde_json::Value =
267 serde_json::from_str(&m.data).unwrap_or(serde_json::Value::Null);
268 let role = data_v["role"].as_str().unwrap_or("user");
269 let mut info = data_v.clone();
270 info["id"] = serde_json::Value::String(m.id.clone());
271 info["sessionID"] = serde_json::Value::String(sid.clone());
272
273 let mut ops = vec![ocserve_store::WriteOp::Sql {
274 sql: "INSERT INTO msg (id, session_id, role, seq, time_created, info) VALUES (?1, ?2, ?3, ?4, ?5, ?6)".into(),
275 params: vec![
276 m.id.clone().into(),
277 sid.clone().into(),
278 role.to_string().into(),
279 seq.into(),
280 m.time_created.into(),
281 info.to_string().into(),
282 ],
283 }];
284 let mut merged_parts: Vec<serde_json::Value> = Vec::new();
285 for (part_seq, p) in (1_i64..).zip(m.parts.iter()) {
286 let mut pv: serde_json::Value =
287 serde_json::from_str(&p.data).unwrap_or(serde_json::Value::Null);
288 pv["id"] = serde_json::Value::String(p.id.clone());
289 pv["sessionID"] = serde_json::Value::String(sid.clone());
290 pv["messageID"] = serde_json::Value::String(m.id.clone());
291 let ptype = pv["type"].as_str().unwrap_or("text");
292 let (inline, sha): (Option<String>, Option<String>) =
293 if p.data.len() > ocserve_store::INLINE_PART_MAX {
294 (None, Some(st.blobs.put(p.data.as_bytes())?.0))
295 } else {
296 (Some(p.data.clone()), None)
297 };
298 ops.extend(ocserve_store::part_row_ops(&ocserve_store::PartRow {
299 id: &p.id,
300 message_id: &m.id,
301 session_id: &sid,
302 seq: part_seq,
303 ptype,
304 byte_len: p.data.len() as i64,
305 inline,
306 blob_sha: sha,
307 text: &p.data,
308 }));
309 merged_parts.push(pv);
310 stats.parts += 1;
311 }
312 if let Some((title, t_upd)) = legacy_sessions.get(&sid) {
314 ops.push(ocserve_store::WriteOp::Sql {
315 sql: "UPDATE session SET title = ?2, time_updated = ?3 WHERE id = ?1".into(),
316 params: vec![sid.clone().into(), title.clone().into(), (*t_upd).into()],
317 });
318 }
319 st.writer.write(ops)?;
320
321 emit_durable_http(
323 st,
324 &sid,
325 "message.updated",
326 serde_json::json!({
327 "sessionID": sid,
328 "info": info,
329 }),
330 )
331 .map_err(|e| anyhow::anyhow!("emit message.updated: {} {}", e.name, e.message))?;
332 for pv in merged_parts {
333 emit_durable_http(
334 st,
335 &sid,
336 "message.part.updated",
337 serde_json::json!({
338 "sessionID": sid,
339 "part": pv,
340 }),
341 )
342 .map_err(|e| anyhow::anyhow!("emit part.updated: {} {}", e.name, e.message))?;
343 }
344 stats.messages += 1;
345 }
346
347 if let Some((id, t)) = last {
348 let cur = ocserve_store::encode_cursor(&id, t);
349 st.writer.write(vec![ocserve_store::WriteOp::Sql {
350 sql: "UPDATE import_sync SET cursor = ?2, last_sync_ms = ?3 WHERE session_id = ?1"
351 .into(),
352 params: vec![sid.clone().into(), cur.into(), now_ms.into()],
353 }])?;
354 }
355 }
356 Ok(stats)
357}