use anyhow::Result;
use rusqlite::{Connection, OptionalExtension, params};
pub(super) 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(())
}
pub(super) fn ensure_thread_index_schema(conn: &Connection) -> Result<()> {
if !table_exists(conn, "thread_index")? {
create_thread_index_table(conn)?;
return Ok(());
}
ensure_column(
conn,
"thread_index",
"runtime_id",
"TEXT NOT NULL DEFAULT 'primary'",
)?;
ensure_column(
conn,
"thread_index",
"archived",
"INTEGER NOT NULL DEFAULT 0",
)?;
if column_exists(conn, "thread_index", "workspace_id")?
|| column_exists(conn, "thread_index", "note")?
{
rebuild_thread_index_without_legacy_columns(conn)?;
}
Ok(())
}
fn create_thread_index_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS thread_index (
thread_id TEXT PRIMARY KEY,
runtime_id TEXT NOT NULL DEFAULT 'primary',
name 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
);",
)?;
Ok(())
}
fn rebuild_thread_index_without_legacy_columns(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE thread_index RENAME TO thread_index_legacy;
CREATE TABLE thread_index (
thread_id TEXT PRIMARY KEY,
runtime_id TEXT NOT NULL DEFAULT 'primary',
name 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
);
INSERT INTO thread_index (
thread_id, runtime_id, name, preview, cwd, status,
model_provider, source, created_at_ms, updated_at_ms, is_loaded,
is_active, archived, raw_json
)
SELECT
thread_id,
COALESCE(runtime_id, 'primary'),
name,
preview,
cwd,
status,
model_provider,
source,
created_at_ms,
updated_at_ms,
is_loaded,
is_active,
archived,
raw_json
FROM thread_index_legacy;
DROP TABLE thread_index_legacy;",
)?;
Ok(())
}
pub(super) fn migrate_legacy_workspaces(conn: &Connection) -> Result<()> {
if !table_exists(conn, "workspaces")? {
return Ok(());
}
conn.execute_batch(
"INSERT OR IGNORE INTO directory_bookmarks (path, display_name, created_at_ms, updated_at_ms)
SELECT root_path, display_name, created_at_ms, updated_at_ms
FROM workspaces;
DROP TABLE workspaces;",
)?;
Ok(())
}
fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
let exists = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
params![table],
|_| Ok(()),
)
.optional()?
.is_some();
Ok(exists)
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
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(true);
}
}
Ok(false)
}