use axum::body::Body;
use axum::http::Request;
use ocserve_http::{AppState, LlmRegistry, Payloads, Wires, sync};
use ocserve_store::WriteOp;
use std::path::Path;
use tower::ServiceExt;
fn state(dir: &std::path::Path) -> std::sync::Arc<AppState> {
let db = ocserve_store::writer::db_path(dir);
let writer = std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).unwrap());
let blobs = std::sync::Arc::new(ocserve_store::BlobStore::new(dir.join("blobs")).unwrap());
let llm = LlmRegistry {
limits: std::collections::HashMap::new(),
endpoints: [("fake".into(), ("http://127.0.0.1:9".into(), String::new()))]
.into_iter()
.collect(),
pricing: Default::default(),
default_model: ("fake".into(), "m".into()),
systems: Default::default(),
default_agent: "build".into(),
};
AppState::with_wiring(
None,
Payloads::default(),
Wires {
db,
blobs,
writer,
llm,
},
)
}
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ocserve-sync-{}-{}-{name}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn legacy_fixture(path: &Path) {
let conn = rusqlite::Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, time_updated INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER,
time_updated INTEGER, data TEXT);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT,
time_created INTEGER, time_updated INTEGER, data TEXT);
CREATE INDEX message_session_time_created_id_idx ON message(session_id, time_created, id);
CREATE INDEX part_message_id_id_idx ON part(message_id, id);",
)
.unwrap();
conn.execute_batch(
"INSERT INTO session VALUES ('ses_s','legacy title',9999);
INSERT INTO session VALUES ('ses_c','cap session',10000);
INSERT INTO message VALUES ('msg_old','ses_s',80,80,'{\"role\":\"user\"}');
INSERT INTO message VALUES ('msg_a','ses_s',90,90,'{\"role\":\"user\"}');
INSERT INTO message VALUES ('msg_z90','ses_s',90,90,'{\"role\":\"assistant\"}');
INSERT INTO message VALUES ('msg_m100a','ses_s',100,100,'{\"role\":\"user\"}');
INSERT INTO message VALUES ('msg_m100b','ses_s',100,100,'{\"role\":\"assistant\"}');
INSERT INTO message VALUES ('msg_m101','ses_s',101,101,'{\"role\":\"assistant\"}');",
)
.unwrap();
conn.execute_batch(
"INSERT INTO part VALUES ('prt_t','msg_m100a','ses_s',100,100,'{\"type\":\"text\",\"text\":\"hi\"}');",
)
.unwrap();
let big = "x".repeat(9_000);
conn.execute(
"INSERT INTO part VALUES (?1,'msg_m101','ses_s',101,101,?2)",
rusqlite::params!["prt_small", "{\"type\":\"text\",\"text\":\"ok\"}"],
)
.unwrap();
conn.execute(
"INSERT INTO part VALUES (?1,'msg_m101','ses_s',101,101,?2)",
rusqlite::params![
"prt_big",
format!("{{\"type\":\"tool\",\"output\":\"{big}\"}}")
],
)
.unwrap();
conn.execute_batch("BEGIN").unwrap();
for i in 0..600 {
conn.execute(
"INSERT INTO message VALUES (?1,'ses_c',?,?,'{\"role\":\"user\"}')",
rusqlite::params![format!("msg_c{i:04}"), 1000 + i, 1000 + i],
)
.unwrap();
}
conn.execute_batch("COMMIT").unwrap();
}
fn seed_ocserve_import(st: &AppState, sid: &str, base_id: &str) {
st.writer
.write(vec![
WriteOp::Sql {
sql: "INSERT INTO session (id, project_id, directory, path, slug, title, version, time_created, time_updated) VALUES (?1,'global','/w','s','s','imported','1',1,1)".into(),
params: vec![sid.into()],
},
WriteOp::Sql {
sql: "INSERT INTO msg (id, session_id, role, seq, time_created, info) VALUES (?2,?1,'user',1,90,'{}')".into(),
params: vec![sid.into(), base_id.into()],
},
])
.unwrap();
}
fn msg_count(st: &AppState, sid: &str) -> i64 {
let conn = ocserve_store::pragma::open_reader(&st.db).unwrap();
conn.query_row("SELECT count(*) FROM msg WHERE session_id=?1", [sid], |r| {
r.get(0)
})
.unwrap()
}
#[test]
fn delta_lands_parts_cursor_and_session_refresh() {
let dir = tmp("flow");
let legacy = dir.join("legacy.db");
legacy_fixture(&legacy);
let st = state(&dir);
seed_ocserve_import(&st, "ses_s", "msg_b90");
let mut rx = st.bus.subscribe();
let stats = sync::sync_tick_at(&st, &legacy).expect("tick");
assert_eq!(stats.messages, 6, "stats: {stats:?}");
assert_eq!(stats.parts, 3);
assert_eq!(msg_count(&st, "ses_s"), 7, "msg_b90 + 6");
let conn = ocserve_store::pragma::open_reader(&st.db).unwrap();
let (seq, role): (i64, String) = conn
.query_row("SELECT seq, role FROM msg WHERE id='msg_z90'", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(seq, 4, "pull ASC order: old=2, a=3, z90=4");
assert_eq!(role, "assistant", "role from legacy data");
let (inline, sha): (Option<String>, Option<String>) = conn
.query_row(
"SELECT inline, blob_sha FROM msg_part WHERE id='prt_big'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(
inline.is_none() && sha.is_some(),
"oversized part → blob store"
);
let (title, tupd): (String, i64) = conn
.query_row(
"SELECT title, time_updated FROM session WHERE id='ses_s'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
(title.as_str(), tupd),
("legacy title", 9999),
"legacy wins"
);
let stats2 = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(stats2.messages, 0, "second tick: {stats2:?}");
assert_eq!(msg_count(&st, "ses_s"), 7);
let mut saw_msg = 0;
let mut saw_part = 0;
while let Ok(frame) = rx.try_recv() {
let s = frame.to_string();
if s.contains("\"type\":\"message.updated\"") && s.contains("ses_s") {
saw_msg += 1;
}
if s.contains("\"type\":\"message.part.updated\"") && s.contains("ses_s") {
saw_part += 1;
}
}
assert_eq!(saw_msg, 6, "message.updated frames");
assert_eq!(saw_part, 3, "part.updated frames");
st.writer
.write(vec![WriteOp::Sql {
sql: "INSERT INTO session (id, project_id, directory, path, slug, title, version, time_created, time_updated) VALUES ('ses_only','global','/w','s','s','mine','1',1,1)".into(),
params: vec![],
}])
.unwrap();
sync::sync_tick_at(&st, &legacy).unwrap();
let adopted: i64 = conn
.query_row(
"SELECT count(*) FROM import_sync WHERE session_id='ses_only'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(adopted, 0, "ocserve-only session must not adopt");
}
#[test]
fn crash_retry_repull_is_idempotent_and_counts_only_new() {
let dir = tmp("retry");
let legacy = dir.join("legacy.db");
legacy_fixture(&legacy);
let st = state(&dir);
seed_ocserve_import(&st, "ses_s", "msg_b90");
sync::sync_tick_at(&st, &legacy).unwrap();
let cur = ocserve_store::encode_cursor("msg_b90", 90);
st.writer
.write(vec![WriteOp::Sql {
sql: "UPDATE import_sync SET cursor=?2 WHERE session_id=?1".into(),
params: vec!["ses_s".into(), cur.into()],
}])
.unwrap();
let stats = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(stats.messages, 0, "re-pull must not re-count existing rows");
assert_eq!(msg_count(&st, "ses_s"), 7, "no duplicate rows");
let mut rx_dups = 0;
let mut rx = st.bus.subscribe();
while rx.try_recv().is_ok() {}
let _ = stats;
rx_dups += 0;
assert_eq!(rx_dups, 0);
}
#[test]
fn per_session_cap_spans_ticks_with_backlog_flag() {
let dir = tmp("cap");
let legacy = dir.join("legacy.db");
legacy_fixture(&legacy);
let st = state(&dir);
seed_ocserve_import(&st, "ses_c", "msg_cc90"); let t1 = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(t1.messages, 500, "cap: {t1:?}");
assert!(t1.backlog, "must report backlog");
let t2 = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(t2.messages, 100, "tick2: {t2:?}");
assert!(!t2.backlog, "drained");
let t3 = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(t3.messages, 0);
assert_eq!(msg_count(&st, "ses_c"), 601, "baseline + 600 legacy");
}
#[test]
fn missing_legacy_source_is_an_error_not_a_panic() {
let dir = tmp("missing");
let st = state(&dir);
let err = sync::sync_tick_at(&st, &dir.join("nope.db")).unwrap_err();
assert!(err.to_string().contains("not found"), "err: {err:#}");
}
#[tokio::test]
async fn synced_messages_are_served_over_http() {
let dir = tmp("http");
let legacy = dir.join("legacy.db");
legacy_fixture(&legacy);
let st = state(&dir);
seed_ocserve_import(&st, "ses_s", "msg_b90");
sync::sync_tick_at(&st, &legacy).unwrap();
let app = ocserve_http::router(st.clone());
let resp = app
.oneshot(
Request::builder()
.uri("/session/ses_s/message?limit=3")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let ids: Vec<&str> = v
.as_array()
.unwrap()
.iter()
.map(|m| m["info"]["id"].as_str().unwrap())
.collect();
assert_eq!(
ids,
["msg_m100a", "msg_m100b", "msg_m101"],
"newest3 after sync (ASC)"
);
}
#[test]
fn clamp_fills_hole_left_by_ocserve_side_send() {
let dir = tmp("clamp");
let legacy = dir.join("legacy.db");
{
let conn = rusqlite::Connection::open(&legacy).unwrap();
conn.execute_batch(
"CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, time_updated INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER,
time_updated INTEGER, data TEXT);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT,
time_created INTEGER, time_updated INTEGER, data TEXT);
INSERT INTO session VALUES ('ses_h','legacy title',9999);
INSERT INTO message VALUES ('msg_a','ses_h',90,90,'{\"role\":\"user\"}');",
)
.unwrap();
for i in 0..10 {
conn.execute(
"INSERT INTO message VALUES (?1,'ses_h',?,?,'{\"role\":\"assistant\"}')",
rusqlite::params![format!("msg_gap{i:02}"), 100 + i * 10, 100 + i * 10],
)
.unwrap();
}
conn.execute(
"INSERT INTO message VALUES ('msg_leg_latest','ses_h',500,500,'{\"role\":\"assistant\"}')",
[],
)
.unwrap();
}
let st = state(&dir);
st.writer
.write(vec![
WriteOp::Sql {
sql: "INSERT INTO session (id, project_id, directory, path, slug, title, version, time_created, time_updated) VALUES ('ses_h','global','/w','s','s','imported','1',1,1)".into(),
params: vec![],
},
WriteOp::Sql {
sql: "INSERT INTO msg (id, session_id, role, seq, time_created, info) VALUES ('msg_a','ses_h','user',1,90,'{}')".into(),
params: vec![],
},
WriteOp::Sql {
sql: "INSERT INTO msg (id, session_id, role, seq, time_created, info) VALUES ('msg_001a0f0000000000000000send','ses_h','user',2,200,'{}')".into(),
params: vec![],
},
])
.unwrap();
let stats = sync::sync_tick_at(&st, &legacy).unwrap();
assert_eq!(stats.messages, 11, "clamp must pull the hole: {stats:?}");
let conn = ocserve_store::pragma::open_reader(&st.db).unwrap();
let gap: i64 = conn
.query_row(
"SELECT count(*) FROM msg WHERE session_id='ses_h' AND id LIKE 'msg_gap%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(gap, 10, "middle messages landed");
let rows: Vec<String> = {
let mut stmt = conn
.prepare("SELECT id FROM msg WHERE session_id='ses_h' ORDER BY time_created, id")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
let pos_gap = rows.iter().position(|i| i == "msg_gap00").unwrap();
let pos_send = rows.iter().position(|i| i.ends_with("send")).unwrap();
assert!(pos_gap < pos_send, "time order: {rows:?}");
}