Skip to main content

ocserve_http/
sync.rs

1//! Legacy→ocserve delta sync (development bridge; user: "development
2//! machinery until the final migration"). Additive-only: pulls messages/
3//! parts for sessions that exist in BOTH databases, emits live SSE so an
4//! open oc-remote view updates, refreshes session title/time (legacy wins
5//! for imported sessions — documented divergence, TESTING §1.6).
6//!
7//! Safety: legacy is opened READ-ONLY (store::open_legacy_ro), fail-soft
8//! (missing/locked legacy → Err for the caller to log, never panics), kill
9//! switch `OCSERVE_LEGACY_SYNC=0`, path override `OCSERVE_LEGACY_DB`.
10
11use 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    /// more work remained after the per-session/global caps
25    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
42/// One sync pass (env-configured source). `Err` = source unavailable
43/// (caller logs + error metric).
44pub fn sync_tick(st: &Arc<AppState>) -> Result<SyncStats> {
45    sync_tick_at(st, &legacy_db_path())
46}
47
48/// One sync pass against an explicit source path (tests use this — no env).
49pub 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    // Attach our own DB read-only so hole detection can anti-join against
55    // live ocserve rows (never writes; both sides read-only).
56    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    // ocserve's sessions ∩ legacy's sessions = the imported (adopted) set
65    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    // adoption: imported sessions get a sync row seeded at ocserve's newest
103    // message (first pull only brings the legacy-side gap — not a re-import)
104    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    // sync rows (adopted set)
136    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        // Clamp to the newest COMMON message: ocserve may hold its own later
157        // sends (user typed from the :4912 view) while legacy's in-between
158        // messages never landed — adoption-at-ocserve-newest then skips that
159        // hole forever (live bug 2026-10-02, fixed here). Cursor only moves
160        // BACK to the newest legacy-id-in-ocserve point; pulls then fill the
161        // gap and the cursor walks forward again (self-healing per tick).
162        // Hole repair (live bug 2026-10-02): a ocserve-side send can sit
163        // AHEAD of legacy messages that never landed, and a cursor adopted/
164        // walked past them skips that region forever. Find the OLDEST legacy
165        // message missing from ocserve; rewind the cursor to its legacy-order
166        // predecessor (or the beginning). Re-pulls are INSERT-OR-IGNORE +
167        // existing-filtered, so the tail costs nothing.
168        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                    // hole starts at the beginning → full re-walk
203                    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        // ids ALREADY in ocserve (idempotent re-pull after a crash: skip them)
222        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; // already landed (crash-retry path)
264            }
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            // session title/time refreshed from legacy (legacy wins — documented)
313            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            // live SSE: reducer merges these into any open view of this session
322            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}