use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::types::Type;
use rusqlite::{Connection, OptionalExtension, params};
use serde::de::DeserializeOwned;
use crate::bridge_protocol::{
PendingServerRequestRecord, PersistedEvent, RuntimeRecord, ThreadSummary, WorkspaceRecord,
now_millis,
};
use crate::workspace::{build_workspace_id, canonicalize_directory};
pub const PRIMARY_RUNTIME_ID: &str = "primary";
#[derive(Debug, Clone)]
pub struct Storage {
db_path: PathBuf,
}
impl Storage {
pub fn open(db_path: PathBuf) -> Result<Self> {
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建数据库目录失败: {}", parent.display()))?;
}
let storage = Self { db_path };
storage.migrate()?;
storage.clear_pending_requests()?;
storage.clear_legacy_pending_approvals()?;
Ok(storage)
}
pub fn ensure_primary_runtime(
&self,
codex_home: Option<String>,
codex_binary: String,
) -> Result<RuntimeRecord> {
if let Some(existing) = self.get_runtime(PRIMARY_RUNTIME_ID)? {
let desired_home = codex_home.or(existing.codex_home.clone());
let desired_binary = if codex_binary.trim().is_empty() {
existing.codex_binary.clone()
} else {
codex_binary
};
let needs_update = existing.codex_home != desired_home
|| existing.codex_binary != desired_binary
|| !existing.is_primary
|| !existing.auto_start;
if !needs_update {
return Ok(existing);
}
let updated = RuntimeRecord {
codex_home: desired_home,
codex_binary: desired_binary,
is_primary: true,
auto_start: true,
updated_at_ms: now_millis(),
..existing
};
self.upsert_runtime(&updated)?;
return Ok(updated);
}
let now = now_millis();
let record = RuntimeRecord {
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
display_name: "Primary".to_string(),
codex_home,
codex_binary,
is_primary: true,
auto_start: true,
created_at_ms: now,
updated_at_ms: now,
};
self.upsert_runtime(&record)?;
Ok(record)
}
pub fn list_runtimes(&self) -> Result<Vec<RuntimeRecord>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT raw_json
FROM runtimes
ORDER BY is_primary DESC, created_at_ms ASC",
)?;
let rows = stmt.query_map([], |row| {
let raw: String = row.get(0)?;
decode_json_row(raw)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_runtime(&self, runtime_id: &str) -> Result<Option<RuntimeRecord>> {
let conn = self.connect()?;
let record = conn
.query_row(
"SELECT raw_json FROM runtimes WHERE runtime_id = ?1",
params![runtime_id],
|row| {
let raw: String = row.get(0)?;
decode_json_row(raw)
},
)
.optional()?;
Ok(record)
}
pub fn upsert_runtime(&self, runtime: &RuntimeRecord) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"INSERT INTO runtimes (
runtime_id, display_name, codex_home, codex_binary, is_primary,
auto_start, created_at_ms, updated_at_ms, raw_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(runtime_id) DO UPDATE SET
display_name = excluded.display_name,
codex_home = excluded.codex_home,
codex_binary = excluded.codex_binary,
is_primary = excluded.is_primary,
auto_start = excluded.auto_start,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms,
raw_json = excluded.raw_json",
params![
runtime.runtime_id,
runtime.display_name,
runtime.codex_home,
runtime.codex_binary,
if runtime.is_primary { 1_i64 } else { 0_i64 },
if runtime.auto_start { 1_i64 } else { 0_i64 },
runtime.created_at_ms,
runtime.updated_at_ms,
serde_json::to_string(runtime)?,
],
)?;
Ok(())
}
pub fn remove_runtime(&self, runtime_id: &str) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"DELETE FROM runtimes WHERE runtime_id = ?1",
params![runtime_id],
)?;
Ok(())
}
pub fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT id, display_name, root_path, trusted, created_at_ms, updated_at_ms
FROM workspaces
ORDER BY display_name COLLATE NOCASE ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok(WorkspaceRecord {
id: row.get(0)?,
display_name: row.get(1)?,
root_path: row.get(2)?,
trusted: row.get::<_, i64>(3)? != 0,
created_at_ms: row.get(4)?,
updated_at_ms: row.get(5)?,
})
})?;
let workspaces = rows.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(workspaces)
}
pub fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
let conn = self.connect()?;
let workspace = conn
.query_row(
"SELECT id, display_name, root_path, trusted, created_at_ms, updated_at_ms
FROM workspaces
WHERE id = ?1",
params![workspace_id],
|row| {
Ok(WorkspaceRecord {
id: row.get(0)?,
display_name: row.get(1)?,
root_path: row.get(2)?,
trusted: row.get::<_, i64>(3)? != 0,
created_at_ms: row.get(4)?,
updated_at_ms: row.get(5)?,
})
},
)
.optional()?;
Ok(workspace)
}
pub fn upsert_workspace(
&self,
display_name: &str,
root_path: &Path,
trusted: bool,
) -> Result<WorkspaceRecord> {
let canonical = canonicalize_directory(root_path)?;
let now = now_millis();
let conn = self.connect()?;
let existing = conn
.query_row(
"SELECT id, created_at_ms FROM workspaces WHERE root_path = ?1",
params![canonical.to_string_lossy().to_string()],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
)
.optional()?;
let (id, created_at_ms) = existing.unwrap_or_else(|| (build_workspace_id(&canonical), now));
conn.execute(
"INSERT INTO workspaces (id, display_name, root_path, trusted, created_at_ms, updated_at_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
root_path = excluded.root_path,
trusted = excluded.trusted,
updated_at_ms = excluded.updated_at_ms",
params![
id,
display_name,
canonical.to_string_lossy().to_string(),
if trusted { 1_i64 } else { 0_i64 },
created_at_ms,
now
],
)?;
Ok(WorkspaceRecord {
id,
display_name: display_name.to_string(),
root_path: canonical.to_string_lossy().to_string(),
trusted,
created_at_ms,
updated_at_ms: now,
})
}
pub fn append_event(
&self,
event_type: &str,
runtime_id: Option<&str>,
thread_id: Option<&str>,
payload: &serde_json::Value,
) -> Result<PersistedEvent> {
let now = now_millis();
let conn = self.connect()?;
conn.execute(
"INSERT INTO events (event_type, runtime_id, thread_id, payload, created_at_ms)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
event_type,
runtime_id,
thread_id,
serde_json::to_string(payload)?,
now
],
)?;
let seq = conn.last_insert_rowid();
Ok(PersistedEvent {
seq,
event_type: event_type.to_string(),
runtime_id: runtime_id.map(ToOwned::to_owned),
thread_id: thread_id.map(ToOwned::to_owned),
payload: payload.clone(),
created_at_ms: now,
})
}
pub fn replay_events_after(&self, last_seq: i64) -> Result<Vec<PersistedEvent>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT seq, event_type, runtime_id, thread_id, payload, created_at_ms
FROM events
WHERE seq > ?1
ORDER BY seq ASC",
)?;
let rows = stmt.query_map(params![last_seq], |row| {
Ok(PersistedEvent {
seq: row.get(0)?,
event_type: row.get(1)?,
runtime_id: row.get(2)?,
thread_id: row.get(3)?,
payload: decode_json_row(row.get::<_, String>(4)?)?,
created_at_ms: row.get(5)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn load_thread_events(&self, thread_id: &str) -> Result<Vec<PersistedEvent>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT seq, event_type, runtime_id, thread_id, payload, created_at_ms
FROM events
WHERE thread_id = ?1
ORDER BY seq ASC",
)?;
let rows = stmt.query_map(params![thread_id], |row| {
Ok(PersistedEvent {
seq: row.get(0)?,
event_type: row.get(1)?,
runtime_id: row.get(2)?,
thread_id: row.get(3)?,
payload: decode_json_row(row.get::<_, String>(4)?)?,
created_at_ms: row.get(5)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn save_mobile_session_ack(&self, device_id: &str, last_ack_seq: i64) -> Result<()> {
let conn = self.connect()?;
let now = now_millis();
conn.execute(
"INSERT INTO mobile_sessions (device_id, last_ack_seq, updated_at_ms)
VALUES (?1, ?2, ?3)
ON CONFLICT(device_id) DO UPDATE SET
last_ack_seq = excluded.last_ack_seq,
updated_at_ms = excluded.updated_at_ms",
params![device_id, last_ack_seq, now],
)?;
Ok(())
}
pub fn get_mobile_session_ack(&self, device_id: &str) -> Result<Option<i64>> {
let conn = self.connect()?;
let value = conn
.query_row(
"SELECT last_ack_seq FROM mobile_sessions WHERE device_id = ?1",
params![device_id],
|row| row.get(0),
)
.optional()?;
Ok(value)
}
pub fn upsert_thread_index(&self, thread: &ThreadSummary) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"INSERT INTO thread_index (
thread_id, runtime_id, workspace_id, name, note, preview, cwd, status,
model_provider, source, created_at_ms, updated_at_ms, is_loaded, is_active,
archived, raw_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
ON CONFLICT(thread_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
workspace_id = excluded.workspace_id,
name = excluded.name,
note = COALESCE(excluded.note, thread_index.note),
preview = excluded.preview,
cwd = excluded.cwd,
status = excluded.status,
model_provider = excluded.model_provider,
source = excluded.source,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms,
is_loaded = excluded.is_loaded,
is_active = excluded.is_active,
archived = excluded.archived,
raw_json = excluded.raw_json",
params![
thread.id,
thread.runtime_id,
thread.workspace_id,
thread.name,
thread.note,
thread.preview,
thread.cwd,
thread.status,
thread.model_provider,
thread.source,
thread.created_at,
thread.updated_at,
if thread.is_loaded { 1_i64 } else { 0_i64 },
if thread.is_active { 1_i64 } else { 0_i64 },
if thread.archived { 1_i64 } else { 0_i64 },
serde_json::to_string(thread)?
],
)?;
Ok(())
}
pub fn get_thread_index(&self, thread_id: &str) -> Result<Option<ThreadSummary>> {
let conn = self.connect()?;
let record = conn
.query_row(
"SELECT raw_json, note, archived FROM thread_index WHERE thread_id = ?1",
params![thread_id],
|row| {
decode_thread_row(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, i64>(2)?,
)
},
)
.optional()?;
Ok(record)
}
pub fn list_thread_index(
&self,
workspace_id: Option<&str>,
runtime_id: Option<&str>,
archived: Option<bool>,
search_term: Option<&str>,
) -> Result<Vec<ThreadSummary>> {
let conn = self.connect()?;
let mut sql = String::from(
"SELECT raw_json, note, archived
FROM thread_index",
);
let mut clauses = Vec::new();
let mut values = Vec::new();
if let Some(workspace_id) = workspace_id {
clauses.push("workspace_id = ?");
values.push(rusqlite::types::Value::from(workspace_id.to_string()));
}
if let Some(runtime_id) = runtime_id {
clauses.push("runtime_id = ?");
values.push(rusqlite::types::Value::from(runtime_id.to_string()));
}
if let Some(archived) = archived {
clauses.push("archived = ?");
values.push(rusqlite::types::Value::from(if archived {
1_i64
} else {
0_i64
}));
}
if let Some(search_term) = search_term.filter(|value| !value.trim().is_empty()) {
clauses.push(
"(LOWER(COALESCE(name, '')) LIKE ? OR LOWER(preview) LIKE ? OR \
LOWER(cwd) LIKE ? OR LOWER(COALESCE(note, '')) LIKE ?)",
);
let pattern = format!("%{}%", search_term.trim().to_lowercase());
values.push(rusqlite::types::Value::from(pattern.clone()));
values.push(rusqlite::types::Value::from(pattern.clone()));
values.push(rusqlite::types::Value::from(pattern.clone()));
values.push(rusqlite::types::Value::from(pattern));
}
if !clauses.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&clauses.join(" AND "));
}
sql.push_str(" ORDER BY updated_at_ms DESC");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(values), |row| {
decode_thread_row(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, i64>(2)?,
)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn save_thread_note(&self, thread_id: &str, note: Option<&str>) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"UPDATE thread_index
SET note = ?2
WHERE thread_id = ?1",
params![thread_id, note],
)?;
Ok(())
}
pub fn set_thread_archived(&self, thread_id: &str, archived: bool) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"UPDATE thread_index
SET archived = ?2
WHERE thread_id = ?1",
params![thread_id, if archived { 1_i64 } else { 0_i64 }],
)?;
Ok(())
}
pub fn put_pending_request(&self, request: &PendingServerRequestRecord) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"INSERT INTO pending_server_requests (
request_id, runtime_id, request_type, thread_id, turn_id, item_id, title,
reason, command, cwd, grant_root, tool_name, arguments, questions,
proposed_execpolicy_amendment, network_approval_context, schema,
available_decisions, raw_payload, created_at_ms, raw_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
ON CONFLICT(request_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
request_type = excluded.request_type,
thread_id = excluded.thread_id,
turn_id = excluded.turn_id,
item_id = excluded.item_id,
title = excluded.title,
reason = excluded.reason,
command = excluded.command,
cwd = excluded.cwd,
grant_root = excluded.grant_root,
tool_name = excluded.tool_name,
arguments = excluded.arguments,
questions = excluded.questions,
proposed_execpolicy_amendment = excluded.proposed_execpolicy_amendment,
network_approval_context = excluded.network_approval_context,
schema = excluded.schema,
available_decisions = excluded.available_decisions,
raw_payload = excluded.raw_payload,
created_at_ms = excluded.created_at_ms,
raw_json = excluded.raw_json",
params![
request.request_id,
request.runtime_id,
request.request_type,
request.thread_id,
request.turn_id,
request.item_id,
request.title,
request.reason,
request.command,
request.cwd,
request.grant_root,
request.tool_name,
request.arguments.as_ref().map(serde_json::to_string).transpose()?,
serde_json::to_string(&request.questions)?,
request
.proposed_execpolicy_amendment
.as_ref()
.map(serde_json::to_string)
.transpose()?,
request
.network_approval_context
.as_ref()
.map(serde_json::to_string)
.transpose()?,
request.schema.as_ref().map(serde_json::to_string).transpose()?,
serde_json::to_string(&request.available_decisions)?,
serde_json::to_string(&request.raw_payload)?,
request.created_at_ms,
serde_json::to_string(request)?
],
)?;
Ok(())
}
pub fn get_pending_request(
&self,
request_id: &str,
) -> Result<Option<PendingServerRequestRecord>> {
let conn = self.connect()?;
let record = conn
.query_row(
"SELECT raw_json FROM pending_server_requests WHERE request_id = ?1",
params![request_id],
|row| {
let raw: String = row.get(0)?;
decode_json_row(raw)
},
)
.optional()?;
Ok(record)
}
pub fn list_pending_requests(&self) -> Result<Vec<PendingServerRequestRecord>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT raw_json
FROM pending_server_requests
ORDER BY created_at_ms ASC",
)?;
let rows = stmt.query_map([], |row| {
let raw: String = row.get(0)?;
decode_json_row(raw)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn remove_pending_request(&self, request_id: &str) -> Result<()> {
let conn = self.connect()?;
conn.execute(
"DELETE FROM pending_server_requests WHERE request_id = ?1",
params![request_id],
)?;
Ok(())
}
pub fn clear_pending_requests(&self) -> Result<()> {
let conn = self.connect()?;
conn.execute("DELETE FROM pending_server_requests", [])?;
Ok(())
}
fn clear_legacy_pending_approvals(&self) -> Result<()> {
let conn = self.connect()?;
conn.execute("DELETE FROM pending_approvals", [])?;
Ok(())
}
fn connect(&self) -> Result<Connection> {
let conn = Connection::open(&self.db_path)
.with_context(|| format!("打开数据库失败: {}", self.db_path.display()))?;
conn.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;",
)?;
Ok(conn)
}
fn migrate(&self) -> Result<()> {
let conn = self.connect()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
root_path TEXT NOT NULL UNIQUE,
trusted INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS runtimes (
runtime_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
codex_home TEXT NULL,
codex_binary TEXT NOT NULL,
is_primary INTEGER NOT NULL,
auto_start INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
raw_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_index (
thread_id TEXT PRIMARY KEY,
runtime_id TEXT NOT NULL DEFAULT 'primary',
workspace_id TEXT NULL,
name TEXT NULL,
note TEXT NULL,
preview TEXT NOT NULL,
cwd TEXT NOT NULL,
status TEXT NOT NULL,
model_provider TEXT NOT NULL,
source TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
is_loaded INTEGER NOT NULL,
is_active INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mobile_sessions (
device_id TEXT PRIMARY KEY,
last_ack_seq INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
runtime_id TEXT NULL,
thread_id TEXT NULL,
payload TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pending_approvals (
approval_id TEXT PRIMARY KEY,
runtime_id TEXT NOT NULL DEFAULT 'primary',
thread_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
item_id TEXT NOT NULL,
kind TEXT NOT NULL,
reason TEXT NULL,
command TEXT NULL,
cwd TEXT NULL,
grant_root TEXT NULL,
available_decisions TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
raw_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pending_server_requests (
request_id TEXT PRIMARY KEY,
runtime_id TEXT NOT NULL DEFAULT 'primary',
request_type TEXT NOT NULL,
thread_id TEXT NULL,
turn_id TEXT NULL,
item_id TEXT NULL,
title TEXT NULL,
reason TEXT NULL,
command TEXT NULL,
cwd TEXT NULL,
grant_root TEXT NULL,
tool_name TEXT NULL,
arguments TEXT NULL,
questions TEXT NOT NULL,
proposed_execpolicy_amendment TEXT NULL,
network_approval_context TEXT NULL,
schema TEXT NULL,
available_decisions TEXT NOT NULL,
raw_payload TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
raw_json TEXT NOT NULL
);",
)?;
ensure_column(
&conn,
"thread_index",
"runtime_id",
"TEXT NOT NULL DEFAULT 'primary'",
)?;
ensure_column(&conn, "thread_index", "note", "TEXT NULL")?;
ensure_column(
&conn,
"thread_index",
"archived",
"INTEGER NOT NULL DEFAULT 0",
)?;
ensure_column(&conn, "events", "runtime_id", "TEXT NULL")?;
ensure_column(
&conn,
"pending_approvals",
"runtime_id",
"TEXT NOT NULL DEFAULT 'primary'",
)?;
ensure_column(
&conn,
"pending_server_requests",
"runtime_id",
"TEXT NOT NULL DEFAULT 'primary'",
)?;
Ok(())
}
}
fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let existing: String = row.get(1)?;
if existing == column {
return Ok(());
}
}
conn.execute_batch(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {definition};"
))?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::env;
use std::fs;
use super::Storage;
#[test]
fn ensure_primary_runtime_refreshes_existing_binary() {
let base_dir =
env::temp_dir().join(format!("codex-mobile-storage-test-{}", std::process::id()));
fs::create_dir_all(&base_dir).expect("创建测试目录失败");
let db_path = base_dir.join("bridge.db");
let storage = Storage::open(db_path).expect("打开存储失败");
let initial = storage
.ensure_primary_runtime(None, "codex".to_string())
.expect("创建 primary runtime 失败");
assert_eq!(initial.codex_binary, "codex");
let refreshed = storage
.ensure_primary_runtime(None, "/home/test/.npm-global/bin/codex".to_string())
.expect("刷新 primary runtime 失败");
assert_eq!(refreshed.codex_binary, "/home/test/.npm-global/bin/codex");
}
}
fn decode_json_row<T: DeserializeOwned>(raw: String) -> rusqlite::Result<T> {
serde_json::from_str(&raw)
.map_err(|error| rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error)))
}
fn decode_thread_row(
raw: String,
note: Option<String>,
archived: i64,
) -> rusqlite::Result<ThreadSummary> {
let mut thread: ThreadSummary = decode_json_row(raw)?;
thread.note = note;
thread.archived = archived != 0;
Ok(thread)
}