Files
sovereign_browser_rust/src/db.rs
T

882 lines
32 KiB
Rust

//! SQLite database layer for sovereign_browser
//!
//! Provides persistent storage for events, settings, bookmarks, history,
//! and other browser data. Uses rusqlite with bundled SQLite.
//!
//! Port of `db.c` / `db.h` from the C project.
use rusqlite::{Connection, params};
use std::path::PathBuf;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Global database connection (thread-safe via Mutex).
static DB: Lazy<Mutex<Option<Connection>>> = Lazy::new(|| Mutex::new(None));
/// Get the database directory path.
fn db_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser")
}
/// Get the database path for a given profile pubkey.
fn db_path(pubkey_hex: &str) -> PathBuf {
db_dir().join("profiles").join(pubkey_hex).join("browser.db")
}
/// Check whether `table` has a column named `column`.
fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Box<dyn std::error::Error>> {
Ok(conn.query_row(
&format!(
"SELECT COUNT(*) > 0 FROM pragma_table_info('{}') WHERE name = ?1",
table
),
params![column],
|row| row.get(0),
)?)
}
/// Migrate a legacy `history` table to the current schema.
///
/// The C project's schema was `(id, url UNIQUE, title, visited_at,
/// visit_count)`; the current schema is `(id, url, title, visit_count,
/// last_visit_at, created_at)`. `CREATE TABLE IF NOT EXISTS` skips the
/// existing legacy table, so index creation on `last_visit_at` fails and
/// `db_init` aborts. This runs before schema creation and upgrades the
/// table in place, preserving rows.
fn migrate_legacy_history(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
let has_history: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='history'",
[],
|row| row.get(0),
)?;
if !has_history {
return Ok(());
}
if has_column(conn, "history", "last_visit_at")? {
return Ok(()); // Already the current schema.
}
let legacy_visited_at = has_column(conn, "history", "visited_at")?;
let tx = conn.transaction()?;
if legacy_visited_at {
// C-project schema: map visited_at → last_visit_at/created_at.
println!("[db] Migrating legacy history table (visited_at) to current schema");
tx.execute_batch(
"
CREATE TABLE history_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
visit_count INTEGER NOT NULL DEFAULT 1,
last_visit_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO history_new (url, title, visit_count, last_visit_at, created_at)
SELECT url, COALESCE(title, ''), COALESCE(visit_count, 1),
visited_at, visited_at
FROM history;
DROP TABLE history;
ALTER TABLE history_new RENAME TO history;
",
)?;
} else {
// Unknown legacy schema: keep the data in a backup, start fresh.
println!("[db] Unknown legacy history schema — backing up and recreating");
tx.execute_batch(
"
ALTER TABLE history RENAME TO history_legacy_backup;
CREATE TABLE history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
visit_count INTEGER NOT NULL DEFAULT 1,
last_visit_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
",
)?;
}
tx.commit()?;
Ok(())
}
/// Migrate a legacy `events` table to the current schema.
///
/// The C project's schema was `(id, pubkey, kind, created_at, content, sig,
/// raw_json, fetched_at)`; the current schema replaces `raw_json` with
/// `tags` and adds `relay_url`. `CREATE TABLE IF NOT EXISTS` skips the
/// existing legacy table, so queries selecting `tags`/`relay_url` fail
/// (and callers swallow the error, returning empty results). This runs
/// before schema creation and upgrades the table in place, preserving rows.
fn migrate_legacy_events(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
let has_events: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='events'",
[],
|row| row.get(0),
)?;
if !has_events {
return Ok(());
}
if has_column(conn, "events", "tags")? {
return Ok(()); // Already the current schema.
}
println!("[db] Migrating legacy events table (raw_json) to current schema");
let tx = conn.transaction()?;
tx.execute_batch(
"
CREATE TABLE events_new (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
sig TEXT,
relay_url TEXT,
fetched_at INTEGER NOT NULL
);
INSERT INTO events_new (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at)
SELECT id, pubkey, kind, created_at, COALESCE(content, ''), '[]', sig, NULL, fetched_at
FROM events;
DROP TABLE events;
ALTER TABLE events_new RENAME TO events;
",
)?;
tx.commit()?;
Ok(())
}
/// Open the shared `~/.sovereign_browser/global.db`. Used at startup so
/// no-login / pre-login mode still has storage (agent config, chats,
/// history). Replaced by the per-user database on login. Mirrors the C
/// `db_init_global()` (db.c:205).
pub fn db_init_global() -> Result<(), Box<dyn std::error::Error>> {
db_init_with_path(db_dir().join("global.db"))
}
/// Convert an `agent_messages` table using the short-lived single-column
/// layout (`message` = whole OpenAI JSON) — written by one pre-release
/// Rust build — to the C column layout. No-op for C-schema tables.
fn migrate_single_column_agent_messages(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
if !has_column(conn, "agent_messages", "message")? || has_column(conn, "agent_messages", "role")? {
return Ok(());
}
let rows: Vec<(String, String, i64)> = {
let mut stmt = conn.prepare("SELECT session_id, message, created_at FROM agent_messages ORDER BY id")?;
let mapped = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
mapped.collect::<Result<_, _>>()?
};
let tx = conn.transaction()?;
tx.execute_batch(
"DROP TABLE agent_messages;
CREATE TABLE agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT,
tool_calls TEXT, tool_call_id TEXT, created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE);",
)?;
for (sid, msg, ts) in &rows {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg) {
insert_agent_message(&tx, sid, &v, *ts as u64)?;
}
}
tx.commit()?;
println!("[db] Migrated {} agent message(s) to the shared C schema", rows.len());
Ok(())
}
#[cfg(test)]
mod agent_schema_tests {
use super::*;
/// The exact DDL the C build (db.c:122) writes.
const C_SCHEMA: &str = "
CREATE TABLE agent_sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER);
CREATE TABLE agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT,
content TEXT, tool_calls TEXT, tool_call_id TEXT, created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE);";
fn roundtrip(conn: &Connection) -> Vec<serde_json::Value> {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hi"}),
serde_json::json!({"role": "assistant", "content": "",
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}]}),
serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "{\"ok\":1}"}),
];
for m in &msgs {
insert_agent_message(conn, "s1", m, 1).unwrap();
}
let mut stmt = conn
.prepare("SELECT role, content, tool_calls, tool_call_id FROM agent_messages WHERE session_id = 's1' ORDER BY id")
.unwrap();
let rows: Vec<(String, String, Option<String>, Option<String>)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(rows[1].2.as_deref().map(|s| s.contains("\"c1\"")), Some(true));
assert_eq!(rows[2].3.as_deref(), Some("c1"));
msgs
}
#[test]
fn writes_into_c_created_schema() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(C_SCHEMA).unwrap();
roundtrip(&conn);
}
#[test]
fn migrates_single_column_layout() {
let mut conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE agent_sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER);
CREATE TABLE agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
message TEXT NOT NULL, created_at INTEGER NOT NULL);
INSERT INTO agent_messages (session_id, message, created_at)
VALUES ('s0', '{\"role\":\"user\",\"content\":\"old\"}', 5);",
)
.unwrap();
migrate_single_column_agent_messages(&mut conn).unwrap();
assert!(has_column(&conn, "agent_messages", "role").unwrap());
assert!(!has_column(&conn, "agent_messages", "message").unwrap());
let (role, content): (String, String) = conn
.query_row("SELECT role, content FROM agent_messages", [], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap();
assert_eq!((role.as_str(), content.as_str()), ("user", "old"));
// Idempotent on the (now C-layout) table.
migrate_single_column_agent_messages(&mut conn).unwrap();
roundtrip(&conn);
}
}
/// Initialize the database for a given profile.
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
db_init_with_path(db_path(pubkey_hex))
}
fn db_init_with_path(path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut conn = Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
| rusqlite::OpenFlags::SQLITE_OPEN_CREATE
| rusqlite::OpenFlags::SQLITE_OPEN_FULL_MUTEX,
)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
// Upgrade legacy schemas before CREATE TABLE IF NOT EXISTS runs.
migrate_legacy_history(&mut conn)?;
migrate_legacy_events(&mut conn)?;
migrate_single_column_agent_messages(&mut conn)?;
// Create tables
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
sig TEXT,
relay_url TEXT,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
visit_count INTEGER NOT NULL DEFAULT 1,
last_visit_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
-- Agent chat tables: identical to the C schema (db.c:122) so the
-- C and Rust builds can share ~/.sovereign_browser databases.
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY,
title TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT,
content TEXT,
tool_calls TEXT,
tool_call_id TEXT,
created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
CREATE INDEX IF NOT EXISTS idx_history_last_visit ON history(last_visit_at);
",
)?;
let mut db = DB.lock().unwrap();
*db = Some(conn);
Ok(())
}
/// Close the database connection.
pub fn db_close() {
let mut db = DB.lock().unwrap();
*db = None;
}
/// Store a Nostr event in the database.
pub fn db_store_event(
id: &str,
pubkey: &str,
kind: u64,
created_at: u64,
content: &str,
tags: &str,
sig: Option<&str>,
relay_url: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
conn.execute(
"INSERT OR REPLACE INTO events (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![id, pubkey, kind, created_at, content, tags, sig, relay_url, now],
)?;
}
Ok(())
}
/// Get a Nostr event by ID.
pub fn db_get_event(id: &str) -> Result<Option<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
return Ok(Some(event));
}
}
Ok(None)
}
/// Query events by filter.
pub fn db_query_events(
kinds: &[u64],
authors: Option<&[String]>,
since: Option<u64>,
until: Option<u64>,
limit: Option<u64>,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut sql = String::from(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE 1=1",
);
if !kinds.is_empty() {
let placeholders: Vec<String> = kinds.iter().map(|_| "?".to_string()).collect();
sql.push_str(&format!(" AND kind IN ({})", placeholders.join(",")));
}
if authors.is_some() {
sql.push_str(" AND pubkey = ?");
}
if since.is_some() {
sql.push_str(" AND created_at >= ?");
}
if until.is_some() {
sql.push_str(" AND created_at <= ?");
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(lim) = limit {
sql.push_str(&format!(" LIMIT {}", lim));
}
let mut stmt = conn.prepare(&sql)?;
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
for k in kinds {
param_values.push(Box::new(*k));
}
if let Some(authors) = authors {
if let Some(a) = authors.first() {
param_values.push(Box::new(a.clone()));
}
}
if let Some(s) = since {
param_values.push(Box::new(s));
}
if let Some(u) = until {
param_values.push(Box::new(u));
}
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
let mut rows = stmt.query(params_refs.as_slice())?;
while let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
results.push(event);
}
}
Ok(results)
}
/// Store a key-value pair.
pub fn db_kv_set(key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"INSERT OR REPLACE INTO kv_store (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
}
Ok(())
}
/// Get a key-value pair.
pub fn db_kv_get(key: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare("SELECT value FROM kv_store WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
return Ok(Some(row.get(0)?));
}
}
Ok(None)
}
/// Delete a cached event by id.
pub fn db_delete_event(id: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM events WHERE id = ?1", params![id])?;
}
Ok(())
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Insert one OpenAI-format message as C-schema columns.
fn insert_agent_message(
conn: &Connection,
session_id: &str,
msg: &serde_json::Value,
created_at: u64,
) -> rusqlite::Result<usize> {
let role = msg["role"].as_str().unwrap_or("user");
let content = match &msg["content"] {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
};
let tool_calls = msg
.get("tool_calls")
.filter(|t| t.as_array().map(|a| !a.is_empty()).unwrap_or(false))
.map(|t| t.to_string());
let tool_call_id = msg["tool_call_id"].as_str();
// agent_messages.session_id REFERENCES agent_sessions(id); the bundled
// SQLite enforces foreign keys, so make sure the parent row exists.
conn.execute(
"INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, '', ?2, ?2)",
params![session_id, created_at],
)?;
conn.execute(
"INSERT INTO agent_messages (session_id, role, content, tool_calls, tool_call_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![session_id, role, content, tool_calls, tool_call_id, created_at],
)
}
/// Create (or keep, if it already exists) an agent chat session row.
pub fn db_agent_session_upsert(id: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = now_unix();
conn.execute(
"INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
params![id, title, now],
)?;
}
Ok(())
}
/// Most recently updated agent session id, if any.
pub fn db_agent_session_latest() -> Result<Option<String>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare("SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
return Ok(Some(row.get(0)?));
}
}
Ok(None)
}
/// Append an OpenAI-format message to an agent session.
///
/// Fails if no database is open — callers must not silently continue (an
/// unsaved user message means the LLM never sees the question).
pub fn db_agent_message_add(session_id: &str, message: &serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let conn = db.as_ref().ok_or("No database open")?;
let now = now_unix();
insert_agent_message(conn, session_id, message, now)?;
conn.execute(
"UPDATE agent_sessions SET updated_at = ?2 WHERE id = ?1",
params![session_id, now],
)?;
Ok(())
}
/// All messages of an agent session in OpenAI format, oldest first.
pub fn db_agent_messages_get(session_id: &str) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut out = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT role, content, tool_calls, tool_call_id FROM agent_messages
WHERE session_id = ?1 ORDER BY id ASC",
)?;
let mut rows = stmt.query(params![session_id])?;
while let Some(row) = rows.next()? {
let role: Option<String> = row.get(0)?;
let content: Option<String> = row.get(1)?;
let tool_calls: Option<String> = row.get(2)?;
let tool_call_id: Option<String> = row.get(3)?;
let mut msg = serde_json::json!({
"role": role.unwrap_or_else(|| "user".into()),
"content": content.unwrap_or_default(),
});
if let Some(tc) = tool_calls.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()) {
if tc.as_array().map(|a| !a.is_empty()).unwrap_or(false) {
msg["tool_calls"] = tc;
}
}
if let Some(id) = tool_call_id.filter(|s| !s.is_empty()) {
msg["tool_call_id"] = serde_json::Value::String(id);
}
out.push(msg);
}
}
Ok(out)
}
/// Number of messages stored for an agent session.
pub fn db_agent_message_count(session_id: &str) -> Result<u64, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1",
params![session_id],
|r| r.get(0),
)?;
return Ok(n as u64);
}
Ok(0)
}
/// Replace all messages of an agent session (used when loading a
/// conversation from Nostr).
pub fn db_agent_messages_replace(session_id: &str, messages: &[serde_json::Value]) -> Result<(), Box<dyn std::error::Error>> {
let mut db = DB.lock().unwrap();
let conn = db.as_mut().ok_or("No database open")?;
let now = now_unix();
let tx = conn.transaction()?;
tx.execute(
"INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, '', ?2, ?2)",
params![session_id, now],
)?;
tx.execute("DELETE FROM agent_messages WHERE session_id = ?1", params![session_id])?;
for m in messages {
insert_agent_message(&tx, session_id, m, now)?;
}
tx.execute("UPDATE agent_sessions SET updated_at = ?2 WHERE id = ?1", params![session_id, now])?;
tx.commit()?;
Ok(())
}
/// List agent sessions as (id, title, updated_at), newest first.
pub fn db_agent_sessions_list() -> Result<Vec<(String, String, u64)>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut out = Vec::new();
if let Some(ref conn) = *db {
// title / updated_at are nullable in the C schema.
let mut stmt = conn.prepare(
"SELECT id, title, updated_at FROM agent_sessions ORDER BY updated_at DESC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let title: Option<String> = row.get(1)?;
let updated: Option<i64> = row.get(2)?;
out.push((row.get(0)?, title.unwrap_or_default(), updated.unwrap_or(0).max(0) as u64));
}
}
Ok(out)
}
/// Set an agent session's title (creating the row if needed).
pub fn db_agent_session_set_title(id: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = now_unix();
conn.execute(
"INSERT INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(id) DO UPDATE SET title = excluded.title",
params![id, title, now],
)?;
}
Ok(())
}
/// Delete an agent session and its messages.
pub fn db_agent_session_delete(session_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM agent_messages WHERE session_id = ?1", params![session_id])?;
conn.execute("DELETE FROM agent_sessions WHERE id = ?1", params![session_id])?;
}
Ok(())
}
/// Delete a key-value pair.
pub fn db_kv_delete(key: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM kv_store WHERE key = ?1", params![key])?;
}
Ok(())
}
/// Add a bookmark.
pub fn db_add_bookmark(
title: &str,
url: &str,
folder: &str,
) -> Result<i64, Box<dyn std::error::Error>> {
db_add_bookmark_at(title, url, folder, now_secs())
}
/// Add a bookmark with an explicit creation timestamp (unix seconds).
/// Used by the HTML bookmark importer to preserve original add dates.
pub fn db_add_bookmark_at(
title: &str,
url: &str,
folder: &str,
now: u64,
) -> Result<i64, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"INSERT INTO bookmarks (title, url, folder, position, created_at, updated_at)
VALUES (?1, ?2, ?3, 0, ?4, ?4)",
params![title, url, folder, now],
)?;
return Ok(conn.last_insert_rowid());
}
Ok(0)
}
/// Get all bookmarks.
pub fn db_get_bookmarks() -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, title, url, folder, position, created_at, updated_at FROM bookmarks ORDER BY folder, position",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let bookmark = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"title": row.get::<_, String>(1)?,
"url": row.get::<_, String>(2)?,
"folder": row.get::<_, String>(3)?,
"position": row.get::<_, i64>(4)?,
"created_at": row.get::<_, u64>(5)?,
"updated_at": row.get::<_, u64>(6)?,
});
results.push(bookmark);
}
}
Ok(results)
}
/// Delete a bookmark.
pub fn db_delete_bookmark(id: i64) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
}
Ok(())
}
/// Update a bookmark's title.
pub fn db_update_bookmark_title(id: i64, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET title = ?1, updated_at = ?2 WHERE id = ?3",
params![title, now_secs(), id],
)?;
}
Ok(())
}
/// Update a bookmark's folder path.
pub fn db_update_bookmark_folder(id: i64, folder: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET folder = ?1, updated_at = ?2 WHERE id = ?3",
params![folder, now_secs(), id],
)?;
}
Ok(())
}
/// Current unix timestamp in seconds.
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Add a history entry.
pub fn db_add_history(url: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Try to update existing entry
let updated = conn.execute(
"UPDATE history SET visit_count = visit_count + 1, last_visit_at = ?1, title = ?2 WHERE url = ?3",
params![now, title, url],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO history (url, title, visit_count, last_visit_at, created_at)
VALUES (?1, ?2, 1, ?3, ?3)",
params![url, title, now],
)?;
}
}
Ok(())
}
/// Get history entries.
pub fn db_get_history(limit: u64) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, url, title, visit_count, last_visit_at, created_at
FROM history ORDER BY last_visit_at DESC LIMIT ?1",
)?;
let mut rows = stmt.query(params![limit])?;
while let Some(row) = rows.next()? {
let entry = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"url": row.get::<_, String>(1)?,
"title": row.get::<_, String>(2)?,
"visit_count": row.get::<_, i64>(3)?,
"last_visit_at": row.get::<_, u64>(4)?,
"created_at": row.get::<_, u64>(5)?,
});
results.push(entry);
}
}
Ok(results)
}
/// Clear all history.
pub fn db_clear_history() -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM history", [])?;
}
Ok(())
}