use chrono::Utc;
use fsqlite::Connection;
use fsqlite_types::SqliteValue;
use crate::error::{BeadsError, Result};
use crate::model::{IssueType, Priority, Status};
use crate::util::content_hash_from_parts;
pub const CURRENT_SCHEMA_VERSION: i32 = 11;
const ISSUES_CLOSED_AT_CHECK: &str = "CHECK ((status = 'closed' AND closed_at IS NOT NULL) OR (status = 'tombstone') OR (status NOT IN ('closed', 'tombstone') AND closed_at IS NULL))";
pub const SCHEMA_SQL: &str = r"
-- Issues table
-- Note: TEXT fields use DEFAULT '' for bd (Go) compatibility.
-- bd's sql.Scan doesn't handle NULL well when scanning into string fields.
-- Closed-at invariant is enforced by the CHECK clause below.
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
content_hash TEXT,
title TEXT NOT NULL CHECK(length(title) <= 500),
description TEXT NOT NULL DEFAULT '',
design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4),
issue_type TEXT NOT NULL DEFAULT 'task',
assignee TEXT,
owner TEXT DEFAULT '',
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
close_reason TEXT DEFAULT '',
closed_by_session TEXT DEFAULT '',
due_at DATETIME,
defer_until DATETIME,
external_ref TEXT,
source_system TEXT DEFAULT '',
source_repo TEXT NOT NULL DEFAULT '.',
deleted_at DATETIME,
deleted_by TEXT DEFAULT '',
delete_reason TEXT DEFAULT '',
original_type TEXT DEFAULT '',
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,
compacted_at_commit TEXT,
original_size INTEGER,
sender TEXT DEFAULT '',
ephemeral INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
is_template INTEGER NOT NULL DEFAULT 0,
-- source_repo_path is appended at the end (after is_template) to match
-- the position SQLite assigns to ALTER TABLE ADD COLUMN on existing DBs.
-- This keeps `EXPECTED_ISSUE_COLUMN_ORDER` consistent for both freshly-
-- created and migrated databases. See #289 for context.
source_repo_path TEXT,
-- agent_context (schema v11, #297) carries canonical-JSON governing
-- instructions inherited by descendants on br update --status
-- in_progress / --claim and br show. The on-disk shape is a JSON
-- string; serde_json validation happens at the CLI boundary so the
-- column itself stays a TEXT bag. NULL means no inherited context;
-- emission for descendants silently skips ancestors with NULL.
agent_context TEXT,
CHECK (
(status = 'closed' AND closed_at IS NOT NULL) OR
(status = 'tombstone') OR
(status NOT IN ('closed', 'tombstone') AND closed_at IS NULL)
)
);
-- Primary access patterns
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
CREATE INDEX IF NOT EXISTS idx_issues_issue_type ON issues(issue_type);
CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee) WHERE assignee IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at);
CREATE INDEX IF NOT EXISTS idx_issues_updated_at ON issues(updated_at);
-- Export/sync patterns
CREATE INDEX IF NOT EXISTS idx_issues_content_hash ON issues(content_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_issues_external_ref_unique ON issues(external_ref) WHERE external_ref IS NOT NULL;
-- Special states
CREATE INDEX IF NOT EXISTS idx_issues_ephemeral ON issues(ephemeral) WHERE ephemeral = 1;
CREATE INDEX IF NOT EXISTS idx_issues_pinned ON issues(pinned) WHERE pinned = 1;
CREATE INDEX IF NOT EXISTS idx_issues_tombstone ON issues(status) WHERE status = 'tombstone';
-- Time-based
CREATE INDEX IF NOT EXISTS idx_issues_due_at ON issues(due_at) WHERE due_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_issues_defer_until ON issues(defer_until) WHERE defer_until IS NOT NULL;
-- Ready work composite index (most important for performance)
CREATE INDEX IF NOT EXISTS idx_issues_ready
ON issues(status, priority, created_at)
WHERE status = 'open'
AND ephemeral = 0
AND pinned = 0
AND is_template = 0;
-- Common active list path: non-terminal issues sorted by priority/created_at.
-- Uses ASC on created_at (not DESC) to avoid frankensqlite B-tree ordering
-- divergence with C sqlite3 integrity_check. SQLite reverse-scans the ASC
-- index efficiently for ORDER BY ... created_at DESC queries.
CREATE INDEX IF NOT EXISTS idx_issues_list_active_order
ON issues(priority, created_at)
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL);
-- Dependencies
CREATE TABLE IF NOT EXISTS dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL DEFAULT '',
metadata TEXT DEFAULT '{}',
thread_id TEXT DEFAULT '',
PRIMARY KEY (issue_id, depends_on_id),
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
-- Note: depends_on_id FK intentionally removed to allow external issue references
);
CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_type ON dependencies(type);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type ON dependencies(depends_on_id, type);
CREATE INDEX IF NOT EXISTS idx_dependencies_thread ON dependencies(thread_id) WHERE thread_id != '';
-- Composite for blocking lookups
CREATE INDEX IF NOT EXISTS idx_dependencies_blocking
ON dependencies(depends_on_id, issue_id)
WHERE (type = 'blocks' OR type = 'parent-child' OR type = 'conditional-blocks' OR type = 'waits-for');
-- Labels
CREATE TABLE IF NOT EXISTS labels (
issue_id TEXT NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (issue_id, label),
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);
CREATE INDEX IF NOT EXISTS idx_labels_issue ON labels(issue_id);
-- Comments
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
author TEXT NOT NULL,
text TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_comments_issue ON comments(issue_id);
CREATE INDEX IF NOT EXISTS idx_comments_created_at ON comments(created_at);
-- Events (Audit)
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
old_value TEXT,
new_value TEXT,
comment TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_events_issue ON events(issue_id);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_actor ON events(actor) WHERE actor != '';
-- Config (Runtime)
-- NOTE: Avoid PRIMARY KEY/UNIQUE constraints here because the current
-- storage engine does not reliably maintain unique autoindexes.
-- Application code enforces key replacement via DELETE + INSERT.
CREATE TABLE IF NOT EXISTS config (
key TEXT NOT NULL,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_config_key ON config(key);
-- Metadata
-- Same rationale as config: keep it as key-value with explicit index.
CREATE TABLE IF NOT EXISTS metadata (
key TEXT NOT NULL,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metadata_key ON metadata(key);
-- Dirty Issues (for export)
CREATE TABLE IF NOT EXISTS dirty_issues (
issue_id TEXT PRIMARY KEY,
marked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dirty_issues_marked_at ON dirty_issues(marked_at);
-- Export Hashes (for incremental export)
CREATE TABLE IF NOT EXISTS export_hashes (
issue_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
exported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
-- Blocked Issues Cache (Materialized view)
-- Rebuilt on dependency or status changes.
-- `blocked_by` stores a JSON array of blocking issue IDs.
CREATE TABLE IF NOT EXISTS blocked_issues_cache (
issue_id TEXT PRIMARY KEY,
blocked_by TEXT NOT NULL,
blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_blocked_cache_blocked_at ON blocked_issues_cache(blocked_at);
-- Child Counters (for hierarchical IDs like bd-abc.1, bd-abc.2)
CREATE TABLE IF NOT EXISTS child_counters (
parent_id TEXT PRIMARY KEY,
last_child INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_id) REFERENCES issues(id) ON DELETE CASCADE
);
-- Close metadata (issue #274 — closure-time policy gates Phase 1).
--
-- One row per terminal close. Tier 1 attribution + bypass-policy auditing
-- live here so the issues table stays untouched (avoids breaking JSONL
-- round-trip and the wide SELECT statements throughout sqlite.rs).
--
-- All gate-related columns are nullable / default-valued so older
-- databases upgraded with a single ALTER TABLE chain remain valid.
CREATE TABLE IF NOT EXISTS close_metadata (
issue_id TEXT PRIMARY KEY,
closed_by_agent_name TEXT,
closed_by_harness TEXT,
closed_by_model TEXT,
bypassed_policy INTEGER NOT NULL DEFAULT 0,
bypass_reason TEXT,
policy_gates_fired TEXT,
recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_close_metadata_recorded_at ON close_metadata(recorded_at);
CREATE INDEX IF NOT EXISTS idx_close_metadata_bypassed
ON close_metadata(bypassed_policy)
WHERE bypassed_policy = 1;
";
fn split_sql_statements(sql: &str) -> Vec<&str> {
let bytes = sql.as_bytes();
let len = bytes.len();
let mut stmts = Vec::new();
let mut start = 0; let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_line_comment = false;
let mut in_block_comment = false;
while i < len {
let b = bytes[i];
if in_line_comment {
if b == b'\n' {
in_line_comment = false;
}
i += 1;
continue;
}
if in_block_comment {
if b == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
in_block_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if in_single_quote {
if b == b'\'' {
if i + 1 < len && bytes[i + 1] == b'\'' {
i += 2;
} else {
in_single_quote = false;
i += 1;
}
} else {
i += 1;
}
continue;
}
if in_double_quote {
if b == b'"' {
if i + 1 < len && bytes[i + 1] == b'"' {
i += 2;
} else {
in_double_quote = false;
i += 1;
}
} else {
i += 1;
}
continue;
}
if b == b'\'' {
in_single_quote = true;
i += 1;
} else if b == b'"' {
in_double_quote = true;
i += 1;
} else if b == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
in_line_comment = true;
i += 2;
} else if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
in_block_comment = true;
i += 2;
} else if b == b';' {
let stmt = &sql[start..i];
if !stmt.trim().is_empty() {
stmts.push(stmt.trim());
}
start = i + 1;
i += 1;
} else {
i += 1;
}
}
if start < len {
let stmt = &sql[start..len];
if !stmt.trim().is_empty() {
stmts.push(stmt.trim());
}
}
stmts
}
pub(crate) fn execute_batch(conn: &Connection, sql: &str) -> Result<()> {
for stmt in split_sql_statements(sql) {
let res = conn.execute(stmt);
if let Err(e) = res {
let stripped: String = stmt
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("--"))
.collect::<Vec<_>>()
.join(" ");
let upper = stripped.trim().to_ascii_uppercase();
let is_index =
upper.starts_with("CREATE INDEX") || upper.starts_with("CREATE UNIQUE INDEX");
let is_stale_schema = e.to_string().contains("no such column");
if is_index && is_stale_schema {
continue;
}
eprintln!(
"execute_batch failed on statement: {}\nError: {:?}",
stmt, e
);
return Err(BeadsError::Database(e));
}
}
Ok(())
}
pub fn apply_schema(conn: &Connection) -> Result<()> {
let is_fresh = !table_exists(conn, "issues");
let issues_rebuilt = run_pre_schema_migrations(conn).map_err(|e| {
eprintln!("run_pre_schema_migrations failed: {:?}", e);
e
})?;
execute_batch(conn, SCHEMA_SQL)?;
if is_fresh {
conn.execute(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))
.map_err(|e| {
eprintln!("PRAGMA user_version failed: {:?}", e);
BeadsError::Database(e)
})?;
} else {
run_migrations(conn, issues_rebuilt).map_err(|e| {
eprintln!("run_migrations failed: {:?}", e);
e
})?;
conn.execute(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))
.map_err(|e| {
eprintln!("PRAGMA user_version failed: {:?}", e);
BeadsError::Database(e)
})?;
}
apply_runtime_pragmas(conn).map_err(|e| {
eprintln!("apply_runtime_pragmas failed: {:?}", e);
e
})?;
if is_fresh && let Err(e) = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") {
tracing::debug!(
error = %e,
"wal_checkpoint(TRUNCATE) after fresh bootstrap failed (non-fatal)"
);
}
Ok(())
}
pub fn run_migrations_atomic(conn: &Connection, from: u32, target_version: u32) -> Result<()> {
let row = conn.query_row("PRAGMA user_version")?;
let current = row
.get(0)
.and_then(|v| match v {
fsqlite_types::value::SqliteValue::Integer(n) => u32::try_from(*n).ok(),
_ => None,
})
.unwrap_or(0);
if current != from {
return Err(BeadsError::internal(format!(
"schema migrate refused — user_version mismatch (expected {from}, got {current})"
)));
}
run_migrations(conn, false)?;
conn.execute(&format!("PRAGMA user_version = {target_version}"))
.map_err(BeadsError::Database)?;
let post = conn
.query_row("PRAGMA user_version")?
.get(0)
.and_then(|v| match v {
fsqlite_types::value::SqliteValue::Integer(n) => u32::try_from(*n).ok(),
_ => None,
})
.unwrap_or(0);
if post != target_version {
return Err(BeadsError::internal(format!(
"schema migrate post-check failed — expected user_version={target_version}, observed {post}"
)));
}
Ok(())
}
pub(crate) fn apply_runtime_compatible_schema(conn: &Connection) -> Result<()> {
execute_batch(conn, SCHEMA_SQL)?;
run_migrations(conn, false)?;
conn.execute(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))
.map_err(BeadsError::Database)?;
apply_runtime_pragmas(conn)?;
Ok(())
}
pub(crate) fn apply_runtime_pragmas(conn: &Connection) -> Result<()> {
let journal_mode = conn
.query_row("PRAGMA journal_mode")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_text).map(str::to_owned))
.unwrap_or_default();
if !journal_mode.eq_ignore_ascii_case("wal") {
conn.execute("PRAGMA journal_mode = WAL")?;
}
conn.execute("PRAGMA foreign_keys = ON")?;
conn.execute("PRAGMA synchronous = NORMAL")?;
conn.execute("PRAGMA temp_store = MEMORY")?;
conn.execute("PRAGMA cache_size = -8000")?;
conn.execute("PRAGMA journal_size_limit = 33554432")?;
conn.execute("PRAGMA wal_autocheckpoint = 0")?;
Ok(())
}
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
let escaped_table = table.replace('\'', "''");
let sql = format!("SELECT 1 FROM sqlite_master WHERE type='table' AND name='{escaped_table}'");
conn.query(&sql).is_ok_and(|rows| !rows.is_empty())
}
fn index_exists(conn: &Connection, index: &str) -> bool {
let escaped_index = index.replace('\'', "''");
let sql = format!("SELECT 1 FROM sqlite_master WHERE type='index' AND name='{escaped_index}'");
conn.query(&sql).is_ok_and(|rows| !rows.is_empty())
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
let sql = format!("PRAGMA table_info('{table}')");
conn.query(&sql).is_ok_and(|rows| {
rows.iter()
.any(|row| row.get(1).and_then(SqliteValue::as_text) == Some(column))
})
}
const ISSUE_COLUMNS: &[(&str, &str)] = &[
("content_hash", "TEXT"),
("description", "TEXT NOT NULL DEFAULT ''"),
("design", "TEXT NOT NULL DEFAULT ''"),
("acceptance_criteria", "TEXT NOT NULL DEFAULT ''"),
("notes", "TEXT NOT NULL DEFAULT ''"),
("status", "TEXT NOT NULL DEFAULT 'open'"),
(
"priority",
"INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4)",
),
("issue_type", "TEXT NOT NULL DEFAULT 'task'"),
("assignee", "TEXT"),
("owner", "TEXT DEFAULT ''"),
("estimated_minutes", "INTEGER"),
("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
("created_by", "TEXT DEFAULT ''"),
("updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
("closed_at", "DATETIME"),
("close_reason", "TEXT DEFAULT ''"),
("closed_by_session", "TEXT DEFAULT ''"),
("due_at", "DATETIME"),
("defer_until", "DATETIME"),
("external_ref", "TEXT"),
("source_system", "TEXT DEFAULT ''"),
("source_repo", "TEXT NOT NULL DEFAULT '.'"),
("deleted_at", "DATETIME"),
("deleted_by", "TEXT DEFAULT ''"),
("delete_reason", "TEXT DEFAULT ''"),
("original_type", "TEXT DEFAULT ''"),
("compaction_level", "INTEGER DEFAULT 0"),
("compacted_at", "DATETIME"),
("compacted_at_commit", "TEXT"),
("original_size", "INTEGER"),
("sender", "TEXT DEFAULT ''"),
("ephemeral", "INTEGER NOT NULL DEFAULT 0"),
("pinned", "INTEGER NOT NULL DEFAULT 0"),
("is_template", "INTEGER NOT NULL DEFAULT 0"),
("source_repo_path", "TEXT"),
("agent_context", "TEXT"),
];
const DEPENDENCY_COLUMNS: &[(&str, &str)] = &[
("type", "TEXT NOT NULL DEFAULT 'blocks'"),
("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
("created_by", "TEXT NOT NULL DEFAULT ''"),
("metadata", "TEXT DEFAULT '{}'"),
("thread_id", "TEXT DEFAULT ''"),
];
const COMMENT_COLUMNS: &[(&str, &str)] = &[
("author", "TEXT NOT NULL DEFAULT ''"),
("text", "TEXT NOT NULL DEFAULT ''"),
("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
];
const EVENT_COLUMNS: &[(&str, &str)] = &[
("event_type", "TEXT NOT NULL DEFAULT ''"),
("actor", "TEXT NOT NULL DEFAULT ''"),
("old_value", "TEXT"),
("new_value", "TEXT"),
("comment", "TEXT"),
("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
];
fn ensure_columns(conn: &Connection, table: &str, columns: &[(&str, &str)]) -> Result<()> {
if !table_exists(conn, table) {
return Ok(());
}
for (name, definition) in columns {
if !column_exists(conn, table, name) {
let sql = format!("ALTER TABLE {table} ADD COLUMN {name} {definition}");
conn.execute(&sql)?;
}
}
Ok(())
}
fn table_has_columns(conn: &Connection, table: &str, required_columns: &[&str]) -> bool {
table_exists(conn, table)
&& required_columns
.iter()
.all(|column| column_exists(conn, table, column))
}
fn current_schema_version_declared(conn: &Connection) -> bool {
conn.query_row("PRAGMA user_version")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_integer))
.is_some_and(|version| version >= i64::from(CURRENT_SCHEMA_VERSION))
}
fn core_runtime_tables_exist(conn: &Connection) -> bool {
[
"issues",
"dependencies",
"labels",
"comments",
"events",
"config",
"metadata",
"dirty_issues",
"export_hashes",
"blocked_issues_cache",
"child_counters",
]
.iter()
.all(|table| table_exists(conn, table))
}
const EXPECTED_ISSUE_COLUMN_ORDER: &[&str] = &[
"id",
"content_hash",
"title",
"description",
"design",
"acceptance_criteria",
"notes",
"status",
"priority",
"issue_type",
"assignee",
"owner",
"estimated_minutes",
"created_at",
"created_by",
"updated_at",
"closed_at",
"close_reason",
"closed_by_session",
"due_at",
"defer_until",
"external_ref",
"source_system",
"source_repo",
"deleted_at",
"deleted_by",
"delete_reason",
"original_type",
"compaction_level",
"compacted_at",
"compacted_at_commit",
"original_size",
"sender",
"ephemeral",
"pinned",
"is_template",
"source_repo_path",
"agent_context",
];
fn issues_column_order_matches(conn: &Connection) -> bool {
let Ok(rows) = conn.query("PRAGMA table_info(issues)") else {
return false;
};
let actual_columns: Vec<String> = rows
.iter()
.filter_map(|row| row.get(1).and_then(SqliteValue::as_text).map(String::from))
.collect();
if actual_columns.is_empty() {
return true; }
if actual_columns.len() != EXPECTED_ISSUE_COLUMN_ORDER.len() {
return false;
}
actual_columns
.iter()
.zip(EXPECTED_ISSUE_COLUMN_ORDER.iter())
.all(|(actual, expected)| actual == expected)
}
fn issues_filter_columns_require_v3_rebuild(conn: &Connection) -> bool {
let Ok(rows) = conn.query("PRAGMA table_info('issues')") else {
return true;
};
for column in ["ephemeral", "pinned", "is_template"] {
let Some(row) = rows
.iter()
.find(|row| row.get(1).and_then(SqliteValue::as_text) == Some(column))
else {
return true;
};
let not_null = row.get(3).and_then(SqliteValue::as_integer).unwrap_or(0);
if not_null == 0 {
return true;
}
}
false
}
fn foreign_keys_enabled(conn: &Connection) -> Result<bool> {
let row = conn.query_row("PRAGMA foreign_keys")?;
Ok(row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) == 1)
}
fn restore_foreign_keys(conn: &Connection, operation: &str) -> Result<()> {
conn.execute("PRAGMA foreign_keys = ON")
.map_err(BeadsError::Database)?;
if foreign_keys_enabled(conn)? {
return Ok(());
}
Err(BeadsError::Config(format!(
"failed to re-enable SQLite foreign key enforcement after {operation}: PRAGMA foreign_keys remained OFF"
)))
}
fn finish_foreign_key_suppressed_result<T>(
conn: &Connection,
operation: &str,
result: Result<T>,
) -> Result<T> {
match (result, restore_foreign_keys(conn, operation)) {
(Ok(value), Ok(())) => Ok(value),
(Ok(_), Err(restore_error)) => Err(restore_error),
(Err(original_error), Ok(())) => Err(original_error),
(Err(original_error), Err(restore_error)) => Err(BeadsError::WithContext {
context: format!(
"{operation} failed, and SQLite foreign key enforcement could not be re-enabled: {restore_error}"
),
source: Box::new(original_error),
}),
}
}
fn rebuild_issues_table(conn: &Connection) -> Result<()> {
let existing_rows = conn.query("PRAGMA table_info('issues')")?;
let existing_columns: Vec<String> = existing_rows
.iter()
.filter_map(|row| row.get(1).and_then(SqliteValue::as_text).map(String::from))
.collect();
if existing_columns.is_empty() {
return Ok(()); }
conn.execute("PRAGMA foreign_keys = OFF")?;
let result = (|| -> Result<()> {
conn.execute("BEGIN EXCLUSIVE")?;
if let Err(e) = rebuild_issues_table_inner(conn, &existing_columns) {
let _ = conn.execute("ROLLBACK");
return Err(e);
}
if let Err(e) = conn.execute("COMMIT") {
let _ = conn.execute("ROLLBACK");
return Err(e.into());
}
Ok(())
})();
finish_foreign_key_suppressed_result(conn, "issues table rebuild", result)
}
fn rebuild_issues_table_inner(conn: &Connection, existing_columns: &[String]) -> Result<()> {
let index_rows =
conn.query("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='issues' AND sql IS NOT NULL")?;
for row in &index_rows {
if let Some(name) = row.get(0).and_then(SqliteValue::as_text) {
conn.execute(&format!("DROP INDEX IF EXISTS \"{name}\""))?;
}
}
conn.execute("DROP TABLE IF EXISTS issues_rebuild_tmp")?;
let all_expected: Vec<(&str, &str)> = std::iter::once(("id", "TEXT PRIMARY KEY"))
.chain(std::iter::once(("content_hash", "TEXT")))
.chain(std::iter::once((
"title",
"TEXT NOT NULL CHECK(length(title) <= 500)",
)))
.chain(
ISSUE_COLUMNS
.iter()
.copied()
.filter(|(name, _)| *name != "content_hash"),
)
.collect();
let mut create_cols = Vec::new();
for (col_name, col_def) in &all_expected {
create_cols.push(format!("{col_name} {col_def}"));
}
create_cols.push(ISSUES_CLOSED_AT_CHECK.to_string());
let create_sql = format!(
"CREATE TABLE issues_rebuild_tmp ({})",
create_cols.join(", ")
);
conn.execute(&create_sql)?;
let mut projected_columns = Vec::new();
for (col_name, _) in &all_expected {
if existing_columns.iter().any(|c| c == col_name) {
projected_columns.push((*col_name).to_string());
}
}
if projected_columns.is_empty() {
return Err(BeadsError::Config(
"Cannot rebuild legacy issues table: no canonical issue columns were found".to_string(),
));
}
let copy_out_sql = format!(
"INSERT INTO issues_rebuild_tmp ({cols}) SELECT {cols} FROM issues",
cols = projected_columns.join(", ")
);
conn.execute(©_out_sql)?;
conn.execute("DROP TABLE issues")?;
let create_canonical = format!("CREATE TABLE issues ({})", create_cols.join(", "));
conn.execute(&create_canonical)?;
let copy_back_sql = format!(
"INSERT INTO issues ({cols}) SELECT {cols} FROM issues_rebuild_tmp",
cols = projected_columns.join(", ")
);
conn.execute(©_back_sql)?;
conn.execute("DROP TABLE issues_rebuild_tmp")?;
Ok(())
}
fn backfill_storage_null_in_default_columns(conn: &Connection) {
const COLUMNS: &[(&str, &str, &str)] = &[
("issues", "description", "''"),
("issues", "design", "''"),
("issues", "acceptance_criteria", "''"),
("issues", "notes", "''"),
("issues", "status", "'open'"),
("issues", "priority", "2"),
("issues", "issue_type", "'task'"),
("issues", "source_repo", "'.'"),
("issues", "ephemeral", "0"),
("issues", "pinned", "0"),
("issues", "is_template", "0"),
("dependencies", "type", "'blocks'"),
("dependencies", "created_by", "''"),
("comments", "author", "''"),
("comments", "text", "''"),
("events", "event_type", "''"),
("events", "actor", "''"),
];
for (table, column, default) in COLUMNS {
if !table_exists(conn, table) || !column_exists(conn, table, column) {
continue;
}
let sql =
format!("UPDATE {table} SET {column} = {default} WHERE typeof({column}) = 'null'");
if let Err(err) = conn.execute(&sql) {
tracing::warn!(
table = table,
column = column,
error = %err,
"backfill of storage-NULL default failed; continuing"
);
}
}
}
fn kv_table_uses_primary_key(conn: &Connection, table: &str) -> bool {
let sql = format!("PRAGMA table_info('{table}')");
let Ok(rows) = conn.query(&sql) else {
return false;
};
rows.iter().any(|row| {
let col_name = row.get(1).and_then(SqliteValue::as_text);
let pk_flag = row.get(5).and_then(SqliteValue::as_integer).unwrap_or(0);
col_name == Some("key") && pk_flag > 0
})
}
fn kv_table_needs_canonical_rebuild(conn: &Connection, table: &str, expected_index: &str) -> bool {
let table_has_rows = conn
.query(&format!("PRAGMA table_info('{table}')"))
.is_ok_and(|rows| !rows.is_empty());
table_has_rows
&& (!index_exists(conn, expected_index) || kv_table_uses_primary_key(conn, table))
}
fn rebuild_kv_table_without_unique(conn: &Connection, table: &str) -> Result<()> {
let tmp_table = format!("{table}_rebuild_tmp");
conn.execute("BEGIN EXCLUSIVE")?;
let result = (|| -> Result<()> {
conn.execute(&format!("DROP TABLE IF EXISTS {tmp_table}"))?;
conn.execute(&format!(
"CREATE TABLE {tmp_table} (
key TEXT NOT NULL,
value TEXT NOT NULL
)"
))?;
conn.execute(&format!(
"INSERT INTO {tmp_table} (key, value)
SELECT key, value
FROM {table}"
))?;
conn.execute(&format!("DROP TABLE {table}"))?;
conn.execute(&format!("ALTER TABLE {tmp_table} RENAME TO {table}"))?;
Ok(())
})();
if let Err(err) = result {
let _ = conn.execute("ROLLBACK");
return Err(err);
}
conn.execute("COMMIT")?;
Ok(())
}
fn run_pre_schema_migrations(conn: &Connection) -> Result<bool> {
if kv_table_needs_canonical_rebuild(conn, "config", "idx_config_key") {
rebuild_kv_table_without_unique(conn, "config")?;
}
if kv_table_needs_canonical_rebuild(conn, "metadata", "idx_metadata_key") {
rebuild_kv_table_without_unique(conn, "metadata")?;
}
if table_exists(conn, "blocked_issues_cache") {
let has_blocked_at = column_exists(conn, "blocked_issues_cache", "blocked_at");
let has_blocked_by = column_exists(conn, "blocked_issues_cache", "blocked_by");
let has_issue_id = column_exists(conn, "blocked_issues_cache", "issue_id");
if !has_blocked_at || !has_blocked_by || !has_issue_id {
conn.execute("DROP TABLE IF EXISTS blocked_issues_cache")?;
}
}
let issues_rebuilt = if issues_column_order_matches(conn) {
false
} else {
rebuild_issues_table(conn)?;
true
};
if !issues_rebuilt {
ensure_columns(conn, "issues", ISSUE_COLUMNS)?;
}
ensure_columns(conn, "dependencies", DEPENDENCY_COLUMNS)?;
ensure_columns(conn, "comments", COMMENT_COLUMNS)?;
ensure_columns(conn, "events", EVENT_COLUMNS)?;
Ok(issues_rebuilt)
}
pub(crate) fn runtime_schema_compatible(conn: &Connection) -> bool {
if current_schema_version_declared(conn)
&& core_runtime_tables_exist(conn)
&& !kv_table_uses_primary_key(conn, "config")
&& !kv_table_uses_primary_key(conn, "metadata")
{
return true;
}
let issues_ok = issues_column_order_matches(conn);
let dependencies_ok = table_has_columns(conn, "dependencies", &["issue_id", "depends_on_id"])
&& DEPENDENCY_COLUMNS
.iter()
.all(|(name, _)| column_exists(conn, "dependencies", name));
let labels_ok = table_has_columns(conn, "labels", &["issue_id", "label"]);
let comments_ok = table_has_columns(conn, "comments", &["id", "issue_id"])
&& COMMENT_COLUMNS
.iter()
.all(|(name, _)| column_exists(conn, "comments", name));
let events_ok = table_has_columns(conn, "events", &["id", "issue_id"])
&& EVENT_COLUMNS
.iter()
.all(|(name, _)| column_exists(conn, "events", name));
let config_ok = table_has_columns(conn, "config", &["key", "value"])
&& index_exists(conn, "idx_config_key")
&& !kv_table_uses_primary_key(conn, "config");
let metadata_ok = table_has_columns(conn, "metadata", &["key", "value"])
&& index_exists(conn, "idx_metadata_key")
&& !kv_table_uses_primary_key(conn, "metadata");
let dirty_issues_ok = table_has_columns(conn, "dirty_issues", &["issue_id", "marked_at"]);
let export_hashes_ok = table_has_columns(
conn,
"export_hashes",
&["issue_id", "content_hash", "exported_at"],
);
let blocked_cache_ok = table_has_columns(
conn,
"blocked_issues_cache",
&["issue_id", "blocked_by", "blocked_at"],
);
let child_counters_ok = table_has_columns(conn, "child_counters", &["parent_id", "last_child"]);
let compatible = issues_ok
&& dependencies_ok
&& labels_ok
&& comments_ok
&& events_ok
&& config_ok
&& metadata_ok
&& dirty_issues_ok
&& export_hashes_ok
&& blocked_cache_ok
&& child_counters_ok;
if !compatible {
tracing::debug!(
issues_ok,
dependencies_ok,
labels_ok,
comments_ok,
events_ok,
config_ok,
metadata_ok,
dirty_issues_ok,
export_hashes_ok,
blocked_cache_ok,
child_counters_ok,
"runtime schema compatibility check failed"
);
}
compatible
}
#[allow(clippy::too_many_lines)]
fn run_migrations(conn: &Connection, issues_rebuilt: bool) -> Result<()> {
let has_blocked_by = column_exists(conn, "blocked_issues_cache", "blocked_by");
let has_blocked_at = column_exists(conn, "blocked_issues_cache", "blocked_at");
let has_issue_id = column_exists(conn, "blocked_issues_cache", "issue_id");
if !has_blocked_by || !has_blocked_at || !has_issue_id {
conn.execute("BEGIN IMMEDIATE")?;
let result = (|| -> Result<()> {
conn.execute("DROP TABLE IF EXISTS blocked_issues_cache")?;
conn.execute(
"CREATE TABLE blocked_issues_cache (
issue_id TEXT PRIMARY KEY,
blocked_by TEXT NOT NULL,
blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
)",
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_blocked_cache_blocked_at ON blocked_issues_cache(blocked_at)",
)?;
Ok(())
})();
if let Err(e) = result {
let _ = conn.execute("ROLLBACK");
return Err(e);
}
conn.execute("COMMIT")?;
}
let has_compaction_level = column_exists(conn, "issues", "compaction_level");
if has_compaction_level {
conn.execute("UPDATE issues SET compaction_level = 0 WHERE compaction_level IS NULL")?;
}
let user_version = conn
.query_row("PRAGMA user_version")?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
if !issues_rebuilt {
if user_version < 3
&& table_exists(conn, "issues")
&& issues_filter_columns_require_v3_rebuild(conn)
{
tracing::info!("Migrating database to schema version 3 (NOT NULL filter columns)");
conn.execute("UPDATE issues SET ephemeral = 0 WHERE ephemeral IS NULL")?;
conn.execute("UPDATE issues SET pinned = 0 WHERE pinned IS NULL")?;
conn.execute("UPDATE issues SET is_template = 0 WHERE is_template IS NULL")?;
rebuild_issues_table(conn)?;
conn.execute("DROP INDEX IF EXISTS idx_issues_ready")?;
conn.execute(
"CREATE INDEX idx_issues_ready
ON issues(status, priority, created_at)
WHERE status = 'open'
AND ephemeral = 0
AND pinned = 0
AND is_template = 0",
)?;
}
if user_version < 4 && table_exists(conn, "issues") {
tracing::info!("Migrating database to schema version 4 (ready excludes in_progress)");
conn.execute("DROP INDEX IF EXISTS idx_issues_ready")?;
conn.execute(
"CREATE INDEX idx_issues_ready
ON issues(status, priority, created_at)
WHERE status = 'open'
AND ephemeral = 0
AND pinned = 0
AND is_template = 0",
)?;
}
if user_version < 5 {
tracing::info!(
"Migrating database to schema version 5 (remove DESC from active list index)"
);
conn.execute("DROP INDEX IF EXISTS idx_issues_list_active_order")?;
}
if user_version < 6 && table_exists(conn, "issues") {
tracing::info!(
"Migrating database to schema version 6 (normalize datetime columns and legacy status aliases)"
);
repair_integer_datetime_columns(conn)?;
repair_legacy_status_values(conn)?;
}
}
if user_version < 7 && table_exists(conn, "issues") {
tracing::info!("Migrating database to schema version 7 (Go bd content hashes)");
rebuild_content_hashes_for_go_parity(conn)?;
}
if user_version < 9 {
tracing::info!(
"Migrating database to schema version 9 (close_metadata table for policy gates)"
);
execute_batch(
conn,
r"
CREATE TABLE IF NOT EXISTS close_metadata (
issue_id TEXT PRIMARY KEY,
closed_by_agent_name TEXT,
closed_by_harness TEXT,
closed_by_model TEXT,
bypassed_policy INTEGER NOT NULL DEFAULT 0,
bypass_reason TEXT,
policy_gates_fired TEXT,
recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_close_metadata_recorded_at ON close_metadata(recorded_at);
CREATE INDEX IF NOT EXISTS idx_close_metadata_bypassed
ON close_metadata(bypassed_policy)
WHERE bypassed_policy = 1;
",
)?;
}
if user_version < 8 {
tracing::info!(
"Migrating database to schema version 8 (backfill storage-NULL in NOT NULL DEFAULT columns)"
);
backfill_storage_null_in_default_columns(conn);
}
if !issues_rebuilt
&& user_version < 10
&& table_exists(conn, "issues")
&& !column_exists(conn, "issues", "source_repo_path")
{
tracing::info!(
"Migrating database to schema version 10 (source_repo_path on issues - beads_rust#289)"
);
conn.execute("ALTER TABLE issues ADD COLUMN source_repo_path TEXT")?;
}
if !issues_rebuilt
&& user_version < 11
&& table_exists(conn, "issues")
&& !column_exists(conn, "issues", "agent_context")
{
tracing::info!(
"Migrating database to schema version 11 (agent_context on issues - beads_rust#297)"
);
conn.execute("ALTER TABLE issues ADD COLUMN agent_context TEXT")?;
}
execute_batch(
conn,
r"
-- Export/sync patterns
CREATE INDEX IF NOT EXISTS idx_issues_content_hash ON issues(content_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_issues_external_ref_unique ON issues(external_ref) WHERE external_ref IS NOT NULL;
-- Special states
CREATE INDEX IF NOT EXISTS idx_issues_ephemeral ON issues(ephemeral) WHERE ephemeral = 1;
CREATE INDEX IF NOT EXISTS idx_issues_pinned ON issues(pinned) WHERE pinned = 1;
CREATE INDEX IF NOT EXISTS idx_issues_tombstone ON issues(status) WHERE status = 'tombstone';
-- Time-based
CREATE INDEX IF NOT EXISTS idx_issues_due_at ON issues(due_at) WHERE due_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_issues_defer_until ON issues(defer_until) WHERE defer_until IS NOT NULL;
-- Ready work composite index (most important for performance)
CREATE INDEX IF NOT EXISTS idx_issues_ready
ON issues(status, priority, created_at)
WHERE status = 'open'
AND ephemeral = 0
AND pinned = 0
AND is_template = 0;
-- Common active list path: non-terminal issues sorted by priority/created_at
CREATE INDEX IF NOT EXISTS idx_issues_list_active_order
ON issues(priority, created_at)
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL);
",
)?;
execute_batch(
conn,
r"
DROP INDEX IF EXISTS idx_dependencies_issue_id;
DROP INDEX IF EXISTS idx_dependencies_depends_on_id;
DROP INDEX IF EXISTS idx_dependencies_composite;
DROP INDEX IF EXISTS idx_labels_issue_id;
",
)?;
if table_exists(conn, "dependencies") {
execute_batch(
conn,
r"
CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_type ON dependencies(type);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type ON dependencies(depends_on_id, type);
CREATE INDEX IF NOT EXISTS idx_dependencies_thread ON dependencies(thread_id) WHERE thread_id != '';
-- Composite for blocking lookups
CREATE INDEX IF NOT EXISTS idx_dependencies_blocking
ON dependencies(depends_on_id, issue_id)
WHERE (type = 'blocks' OR type = 'parent-child' OR type = 'conditional-blocks' OR type = 'waits-for');
",
)?;
if column_exists(conn, "dependencies", "thread_id") {
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dependencies_thread ON dependencies(thread_id) WHERE thread_id != ''",
)?;
}
}
if table_exists(conn, "labels") {
execute_batch(
conn,
r"
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);
CREATE INDEX IF NOT EXISTS idx_labels_issue ON labels(issue_id);
",
)?;
}
if table_exists(conn, "comments") {
conn.execute("CREATE INDEX IF NOT EXISTS idx_comments_issue ON comments(issue_id)")?;
}
if table_exists(conn, "events") {
execute_batch(
conn,
r"
CREATE INDEX IF NOT EXISTS idx_events_issue ON events(issue_id);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_actor ON events(actor) WHERE actor != '';
",
)?;
}
Ok(())
}
fn repair_integer_datetime_columns(conn: &Connection) -> Result<()> {
const DATETIME_COLUMNS: &[&str] = &[
"created_at",
"updated_at",
"closed_at",
"due_at",
"defer_until",
"deleted_at",
"compacted_at",
];
for column in DATETIME_COLUMNS {
if !column_exists(conn, "issues", column) {
continue;
}
let sql = format!(
"UPDATE issues SET {column} = \
strftime('%Y-%m-%dT%H:%M:%fZ', CASE \
WHEN ABS({column}) < 10000000000 THEN {column} * 1.0 \
WHEN ABS({column}) < 10000000000000 THEN {column} / 1000.0 \
WHEN ABS({column}) < 10000000000000000 THEN {column} / 1000000.0 \
ELSE {column} / 1000000000.0 \
END, 'unixepoch') \
WHERE typeof({column}) = 'integer'"
);
conn.execute(&sql)?;
}
Ok(())
}
fn repair_legacy_status_values(conn: &Connection) -> Result<()> {
conn.execute(
"UPDATE issues \
SET closed_at = COALESCE(closed_at, updated_at, created_at), \
status = 'closed' \
WHERE LOWER(status) IN ('done', 'complete', 'completed', 'finished', 'resolved')",
)?;
Ok(())
}
fn rebuild_content_hashes_for_go_parity(conn: &Connection) -> Result<usize> {
let rows = conn.query(
"SELECT id, title, description, design, acceptance_criteria, notes, \
status, priority, issue_type, assignee, owner, created_by, \
external_ref, source_system, pinned, is_template \
FROM issues ORDER BY id",
)?;
if rows.is_empty() {
return Ok(0);
}
conn.execute("BEGIN IMMEDIATE")?;
let result = (|| -> Result<usize> {
let mut updated = 0;
let now_str = Utc::now().to_rfc3339();
for row in &rows {
let id = row_text(row, 0).ok_or_else(|| BeadsError::Internal {
message: "content hash migration found issue row without id".to_string(),
})?;
let title = row_text(row, 1).unwrap_or_default();
let description = row_optional_text(row, 2);
let design = row_optional_text(row, 3);
let acceptance_criteria = row_optional_text(row, 4);
let notes = row_optional_text(row, 5);
let status_raw = row_text(row, 6).unwrap_or_else(|| Status::default().as_str().into());
let priority = Priority(
row.get(7)
.and_then(SqliteValue::as_integer)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or_else(|| Priority::default().0),
);
let issue_type_raw =
row_text(row, 8).unwrap_or_else(|| IssueType::default().as_str().into());
let assignee = row_optional_text(row, 9);
let owner = row_optional_text(row, 10);
let created_by = row_optional_text(row, 11);
let external_ref = row_optional_text(row, 12);
let source_system = row_optional_text(row, 13);
let pinned = row_bool(row, 14);
let is_template = row_bool(row, 15);
let status = status_raw
.parse::<Status>()
.unwrap_or_else(|_| Status::Custom(status_raw.clone()));
let issue_type = issue_type_raw
.parse::<IssueType>()
.unwrap_or_else(|_| IssueType::Custom(issue_type_raw.clone()));
let content_hash = content_hash_from_parts(
&title,
description.as_deref(),
design.as_deref(),
acceptance_criteria.as_deref(),
notes.as_deref(),
&status,
&priority,
&issue_type,
assignee.as_deref(),
owner.as_deref(),
created_by.as_deref(),
external_ref.as_deref(),
source_system.as_deref(),
pinned,
is_template,
);
conn.execute_with_params(
"UPDATE issues SET content_hash = ? WHERE id = ?",
&[
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(id.as_str()),
],
)?;
conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from(id.as_str())],
)?;
conn.execute_with_params(
"INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?)",
&[
SqliteValue::from(id.as_str()),
SqliteValue::from(now_str.as_str()),
],
)?;
updated += 1;
}
if table_exists(conn, "export_hashes") {
conn.execute("DELETE FROM export_hashes")?;
}
Ok(updated)
})();
match result {
Ok(updated) => {
conn.execute("COMMIT")?;
Ok(updated)
}
Err(error) => {
let _ = conn.execute("ROLLBACK");
Err(error)
}
}
}
fn row_text(row: &fsqlite::Row, index: usize) -> Option<String> {
row.get(index)
.and_then(SqliteValue::as_text)
.map(str::to_string)
}
fn row_optional_text(row: &fsqlite::Row, index: usize) -> Option<String> {
row_text(row, index).filter(|value| !value.is_empty())
}
fn row_bool(row: &fsqlite::Row, index: usize) -> bool {
row.get(index).is_some_and(|value| {
value.as_integer().map_or_else(
|| value.as_text().is_some_and(|text| text != "0"),
|int| int != 0,
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::BeadsError;
use fsqlite::Connection;
use std::collections::HashSet;
use tempfile::TempDir;
#[test]
fn test_apply_schema() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
apply_schema(&conn).expect("Failed to apply schema");
let tables: Vec<String> = conn
.query("SELECT name FROM sqlite_master WHERE type='table'")
.unwrap()
.iter()
.filter_map(|row| row.get(0).and_then(|v| v.as_text()).map(String::from))
.collect();
assert!(tables.contains(&"issues".to_string()));
assert!(tables.contains(&"dependencies".to_string()));
assert!(tables.contains(&"config".to_string()));
assert!(tables.contains(&"dirty_issues".to_string()));
let row = conn.query_row("PRAGMA journal_mode").unwrap();
let journal_mode = row
.get(0)
.and_then(|v| v.as_text())
.unwrap_or("")
.to_string();
assert!(journal_mode.to_uppercase() == "WAL" || journal_mode.to_uppercase() == "MEMORY");
let row = conn.query_row("PRAGMA foreign_keys").unwrap();
let foreign_keys = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
assert_eq!(foreign_keys, 1);
}
#[test]
fn test_v6_repair_integer_datetime_columns() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
let rows: [(&str, i64); 4] = [
("bug-sec", 1_776_651_488),
("bug-ms", 1_776_651_488_000),
("bug-us", 1_776_651_488_000_000),
("bug-ns", 1_776_651_488_000_000_000),
];
for (id, epoch) in rows {
let stmt = format!(
"INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at, closed_at, close_reason) \
VALUES ('{id}', 'legacy', 'closed', 2, 'task', '2026-04-19T21:34:04.000000000Z', {epoch}, {epoch}, 'Completed')"
);
conn.execute(&stmt).expect("seed integer datetime row");
}
for (id, _) in rows {
let row = conn
.query_row(&format!(
"SELECT typeof(updated_at), typeof(closed_at) FROM issues WHERE id='{id}'"
))
.unwrap();
assert_eq!(
row.get(0).and_then(SqliteValue::as_text),
Some("integer"),
"{id} updated_at should be integer pre-repair"
);
assert_eq!(
row.get(1).and_then(SqliteValue::as_text),
Some("integer"),
"{id} closed_at should be integer pre-repair"
);
}
repair_integer_datetime_columns(&conn).expect("repair should succeed");
for (id, _) in rows {
let row = conn
.query_row(&format!(
"SELECT typeof(updated_at), updated_at, typeof(closed_at), closed_at FROM issues WHERE id='{id}'"
))
.unwrap();
assert_eq!(
row.get(0).and_then(SqliteValue::as_text),
Some("text"),
"{id} updated_at must be TEXT after repair"
);
let updated_at = row
.get(1)
.and_then(SqliteValue::as_text)
.expect("updated_at text");
assert!(
updated_at.starts_with("2026-04-20T02:18:08"),
"{id}: expected 2026-04-20 timestamp, got {updated_at}"
);
assert_eq!(
row.get(2).and_then(SqliteValue::as_text),
Some("text"),
"{id} closed_at must be TEXT after repair"
);
}
repair_integer_datetime_columns(&conn).expect("second pass should succeed");
let row = conn
.query_row("SELECT typeof(updated_at) FROM issues WHERE id='bug-us'")
.unwrap();
assert_eq!(row.get(0).and_then(SqliteValue::as_text), Some("text"));
}
#[test]
fn test_v6_repair_legacy_status_values() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
conn.execute(
"INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at) \
VALUES ('legacy-done', 'bd legacy', 'done', 2, 'task', '2026-04-02T20:00:00Z', '2026-04-03T01:00:00Z')",
).unwrap();
conn.execute(
"INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at) \
VALUES ('legacy-resolved', 'bd legacy', 'Resolved', 2, 'task', '2026-04-02T20:00:00Z', '2026-04-03T01:00:00Z')",
).unwrap();
repair_legacy_status_values(&conn).expect("repair should succeed");
for id in ["legacy-done", "legacy-resolved"] {
let row = conn
.query_row(&format!(
"SELECT status, closed_at FROM issues WHERE id='{id}'"
))
.unwrap();
assert_eq!(
row.get(0).and_then(SqliteValue::as_text),
Some("closed"),
"{id} should be closed"
);
let closed_at = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or_default();
assert!(!closed_at.is_empty(), "{id} closed_at should be populated");
}
}
#[test]
fn test_v7_rebuilds_content_hashes_and_marks_dirty() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
conn.execute(
"INSERT INTO issues (id, content_hash, title, status, priority, issue_type, created_at, updated_at) \
VALUES ('bd-hash', 'old-rust-hash', 'Test', 'open', 2, 'task', '2026-04-02T20:00:00Z', '2026-04-03T01:00:00Z')",
).unwrap();
conn.execute(
"INSERT INTO export_hashes (issue_id, content_hash, exported_at) \
VALUES ('bd-hash', 'old-rust-hash', '2026-04-03T01:00:00Z')",
)
.unwrap();
conn.execute("DELETE FROM dirty_issues").unwrap();
conn.execute("PRAGMA user_version = 6").unwrap();
run_migrations(&conn, false).expect("v7 migration should succeed");
let row = conn
.query_row("SELECT content_hash FROM issues WHERE id = 'bd-hash'")
.unwrap();
assert_eq!(
row.get(0).and_then(SqliteValue::as_text),
Some("c8e7e2783cc1fbb37322ae61efcf0e5c7d79a2cc6203e878fa6556c41742398d"),
"v7 should rewrite stored issue hashes to Go bd canonical values"
);
let dirty_row = conn
.query_row("SELECT COUNT(*) FROM dirty_issues WHERE issue_id = 'bd-hash'")
.unwrap();
assert_eq!(dirty_row.get(0).and_then(SqliteValue::as_integer), Some(1));
let export_row = conn
.query_row("SELECT COUNT(*) FROM export_hashes")
.unwrap();
assert_eq!(export_row.get(0).and_then(SqliteValue::as_integer), Some(0));
}
#[test]
fn test_v7_rebuild_works_when_dirty_issues_has_no_default() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
conn.execute("DROP TABLE dirty_issues").unwrap();
conn.execute(
"CREATE TABLE dirty_issues (
issue_id TEXT PRIMARY KEY,
marked_at TEXT NOT NULL
)",
)
.unwrap();
conn.execute(
"INSERT INTO issues (id, content_hash, title, status, priority, issue_type, created_at, updated_at) \
VALUES ('bd-legacy', 'old-rust-hash', 'Legacy', 'open', 2, 'task', '2026-04-02T20:00:00Z', '2026-04-03T01:00:00Z')",
).unwrap();
conn.execute("PRAGMA user_version = 6").unwrap();
run_migrations(&conn, false)
.expect("v7 migration must succeed against legacy dirty_issues schema");
let dirty_row = conn
.query_row("SELECT COUNT(*) FROM dirty_issues WHERE issue_id = 'bd-legacy'")
.unwrap();
assert_eq!(
dirty_row.get(0).and_then(SqliteValue::as_integer),
Some(1),
"issue must be flagged dirty after v7 even on legacy table shape"
);
}
#[test]
fn test_v8_backfills_storage_null_in_default_columns() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
conn.execute(
"INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at) \
VALUES ('bd-null', 'legacy null row', 'open', 2, 'task', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z')",
)
.expect("seed row");
let columns_to_null: &[&str] = &[
"description",
"design",
"acceptance_criteria",
"notes",
"status",
"priority",
"issue_type",
"source_repo",
"ephemeral",
"pinned",
"is_template",
];
for column in columns_to_null {
let _ = conn.execute(&format!(
"UPDATE issues SET {column} = NULL WHERE id = 'bd-null'"
));
}
backfill_storage_null_in_default_columns(&conn);
for column in columns_to_null {
let row = conn
.query_row(&format!(
"SELECT typeof({column}) FROM issues WHERE id = 'bd-null'"
))
.unwrap();
let actual_type = row.get(0).and_then(SqliteValue::as_text);
assert_ne!(
actual_type,
Some("null"),
"{column} should be backfilled to its declared default (got typeof = null)"
);
}
backfill_storage_null_in_default_columns(&conn);
let row = conn
.query_row("SELECT typeof(notes) FROM issues WHERE id = 'bd-null'")
.unwrap();
assert_ne!(row.get(0).and_then(SqliteValue::as_text), Some("null"));
}
#[test]
fn test_v10_migration_adds_source_repo_path_when_missing() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("legacy_v9.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
content_hash TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2,
issue_type TEXT NOT NULL DEFAULT 'task',
assignee TEXT,
owner TEXT DEFAULT '',
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
close_reason TEXT DEFAULT '',
closed_by_session TEXT DEFAULT '',
due_at DATETIME,
defer_until DATETIME,
external_ref TEXT,
source_system TEXT DEFAULT '',
source_repo TEXT NOT NULL DEFAULT '.',
deleted_at DATETIME,
deleted_by TEXT DEFAULT '',
delete_reason TEXT DEFAULT '',
original_type TEXT DEFAULT '',
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,
compacted_at_commit TEXT,
original_size INTEGER,
sender TEXT DEFAULT '',
ephemeral INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
is_template INTEGER NOT NULL DEFAULT 0
);
",
)
.expect("seed v9 issues table");
conn.execute("PRAGMA user_version = 9")
.expect("stamp legacy user_version");
assert!(
!column_exists(&conn, "issues", "source_repo_path"),
"precondition: legacy v9 table must not have source_repo_path"
);
run_migrations_atomic(&conn, 9, 10).expect("v10 migration must succeed on v9 layout");
assert!(
column_exists(&conn, "issues", "source_repo_path"),
"source_repo_path column should be present after schema upgrade"
);
let stamped = conn
.query_row("PRAGMA user_version")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_integer))
.unwrap_or(-1);
assert_eq!(
stamped, 10,
"user_version should reflect the v10 migration target after run_migrations_atomic(9, 10)"
);
}
#[test]
fn test_apply_schema_file_backed_has_no_duplicate_issues_columns() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("Failed to apply schema");
let row = conn
.query_row("SELECT sql FROM sqlite_master WHERE type='table' AND name='issues'")
.expect("issues table should exist");
let issues_sql = row
.get(0)
.and_then(SqliteValue::as_text)
.expect("issues table SQL should be present");
assert_eq!(
issues_sql.matches("source_repo ").count(),
1,
"issues table SQL should define source_repo exactly once"
);
assert_eq!(
issues_sql.matches("source_repo_path ").count(),
1,
"issues table SQL should define source_repo_path exactly once"
);
assert_eq!(
issues_sql.matches("is_template").count(),
1,
"issues table SQL should define is_template exactly once"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_schema_parity_conformance() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
apply_schema(&conn).expect("Failed to apply schema");
let issues_cols: Vec<(String, String, i32, Option<String>)> = conn
.query("PRAGMA table_info(issues)")
.unwrap()
.iter()
.map(|row| {
(
row.get(1)
.and_then(|v| v.as_text())
.unwrap_or("")
.to_string(),
row.get(2)
.and_then(|v| v.as_text())
.unwrap_or("")
.to_string(),
#[allow(clippy::cast_possible_truncation)]
{
row.get(3).and_then(SqliteValue::as_integer).unwrap_or(0) as i32
},
row.get(4).and_then(|v| v.as_text()).map(String::from),
)
})
.collect();
let col_map: std::collections::HashMap<_, _> = issues_cols
.iter()
.map(|(name, typ, notnull, dflt)| {
(name.as_str(), (typ.as_str(), *notnull, dflt.clone()))
})
.collect();
assert_eq!(
col_map.get("status").map(|c| c.2.as_deref()),
Some(Some("'open'")),
"status should default to 'open'"
);
assert_eq!(
col_map.get("priority").map(|c| c.2.as_deref()),
Some(Some("2")),
"priority should default to 2"
);
assert_eq!(
col_map.get("issue_type").map(|c| c.2.as_deref()),
Some(Some("'task'")),
"issue_type should default to 'task'"
);
assert_eq!(
col_map.get("created_at").map(|c| c.2.as_deref()),
Some(Some("CURRENT_TIMESTAMP")),
"created_at should default to CURRENT_TIMESTAMP"
);
assert_eq!(
col_map.get("updated_at").map(|c| c.2.as_deref()),
Some(Some("CURRENT_TIMESTAMP")),
"updated_at should default to CURRENT_TIMESTAMP"
);
let indexes: HashSet<String> = conn
.query("SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL")
.unwrap()
.iter()
.filter_map(|row| row.get(0).and_then(|v| v.as_text()).map(String::from))
.collect();
assert!(
indexes.contains("idx_issues_status"),
"missing idx_issues_status"
);
assert!(
indexes.contains("idx_issues_priority"),
"missing idx_issues_priority"
);
assert!(
indexes.contains("idx_issues_issue_type"),
"missing idx_issues_issue_type"
);
assert!(
indexes.contains("idx_issues_created_at"),
"missing idx_issues_created_at"
);
assert!(
indexes.contains("idx_issues_updated_at"),
"missing idx_issues_updated_at"
);
assert!(
indexes.contains("idx_issues_content_hash"),
"missing idx_issues_content_hash"
);
assert!(
indexes.contains("idx_issues_external_ref_unique"),
"missing external_ref index"
);
assert!(
indexes.contains("idx_issues_ephemeral"),
"missing idx_issues_ephemeral"
);
assert!(
indexes.contains("idx_issues_pinned"),
"missing idx_issues_pinned"
);
assert!(
indexes.contains("idx_issues_tombstone"),
"missing idx_issues_tombstone"
);
assert!(
indexes.contains("idx_issues_due_at"),
"missing idx_issues_due_at"
);
assert!(
indexes.contains("idx_issues_defer_until"),
"missing idx_issues_defer_until"
);
assert!(
indexes.contains("idx_issues_ready"),
"missing idx_issues_ready composite index"
);
assert!(
indexes.contains("idx_issues_list_active_order"),
"missing idx_issues_list_active_order composite index"
);
let deps_cols: Vec<(String, Option<String>)> = conn
.query("PRAGMA table_info(dependencies)")
.unwrap()
.iter()
.map(|row| {
(
row.get(1)
.and_then(|v| v.as_text())
.unwrap_or("")
.to_string(),
row.get(4).and_then(|v| v.as_text()).map(String::from),
)
})
.collect();
let deps_map: std::collections::HashMap<_, _> = deps_cols
.iter()
.map(|(name, dflt)| (name.as_str(), dflt.clone()))
.collect();
assert_eq!(
deps_map.get("type").cloned().flatten().as_deref(),
Some("'blocks'"),
"dependencies.type should default to 'blocks'"
);
assert_eq!(
deps_map.get("metadata").cloned().flatten().as_deref(),
Some("'{}'"),
"dependencies.metadata should default to '{{}}'"
);
assert!(
indexes.contains("idx_dependencies_issue"),
"missing idx_dependencies_issue"
);
assert!(
indexes.contains("idx_dependencies_depends_on"),
"missing idx_dependencies_depends_on"
);
assert!(
indexes.contains("idx_dependencies_type"),
"missing idx_dependencies_type"
);
assert!(
indexes.contains("idx_dependencies_depends_on_type"),
"missing idx_dependencies_depends_on_type"
);
assert!(
indexes.contains("idx_dependencies_thread"),
"missing idx_dependencies_thread"
);
assert!(
indexes.contains("idx_dependencies_blocking"),
"missing idx_dependencies_blocking"
);
assert!(
indexes.contains("idx_labels_label"),
"missing idx_labels_label"
);
assert!(
indexes.contains("idx_labels_issue"),
"missing idx_labels_issue"
);
let cache_cols: Vec<String> = conn
.query("PRAGMA table_info(blocked_issues_cache)")
.unwrap()
.iter()
.filter_map(|row| row.get(1).and_then(|v| v.as_text()).map(String::from))
.collect();
assert!(
cache_cols.contains(&"issue_id".to_string()),
"blocked_issues_cache should have 'issue_id' column"
);
assert!(
cache_cols.contains(&"blocked_by".to_string()),
"blocked_issues_cache should have 'blocked_by' column (not 'blocked_by_json')"
);
assert!(
cache_cols.contains(&"blocked_at".to_string()),
"blocked_issues_cache should have 'blocked_at' column"
);
assert!(
!cache_cols.contains(&"blocked_by_json".to_string()),
"blocked_issues_cache should NOT have old 'blocked_by_json' column"
);
assert!(
indexes.contains("idx_blocked_cache_blocked_at"),
"missing idx_blocked_cache_blocked_at"
);
conn.execute("INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')")
.expect("Should allow open issue without closed_at");
let result = conn.execute(
"INSERT INTO issues (id, title, status) VALUES ('test-2', 'Closed', 'closed')",
);
if result.is_ok() {
let _ = conn.execute("DELETE FROM issues WHERE id = 'test-2'");
}
conn.execute(
"INSERT INTO issues (id, title, status, closed_at) VALUES ('test-3', 'Closed', 'closed', CURRENT_TIMESTAMP)",
)
.expect("Should allow closed issue with closed_at");
conn.execute(
"INSERT INTO issues (id, title, status) VALUES ('test-4', 'Tombstone', 'tombstone')",
)
.expect("Should allow tombstone without closed_at");
}
#[test]
fn test_migration_blocked_cache_upgrade() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
content_hash TEXT,
external_ref TEXT,
ephemeral INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
is_template INTEGER DEFAULT 0,
compaction_level INTEGER DEFAULT 0,
due_at DATETIME,
defer_until DATETIME
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
PRIMARY KEY (issue_id, depends_on_id)
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
author TEXT NOT NULL,
text TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
old_value TEXT,
new_value TEXT,
comment TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE blocked_issues_cache (
issue_id TEXT PRIMARY KEY,
blocked_by_json TEXT NOT NULL
);
",
)
.unwrap();
run_migrations(&conn, false).unwrap();
let cols: Vec<String> = conn
.query("PRAGMA table_info(blocked_issues_cache)")
.unwrap()
.iter()
.filter_map(|row| row.get(1).and_then(|v| v.as_text()).map(String::from))
.collect();
assert!(
cols.contains(&"blocked_by".to_string()),
"Should have blocked_by"
);
assert!(
cols.contains(&"blocked_at".to_string()),
"Should have blocked_at"
);
assert!(
!cols.contains(&"blocked_by_json".to_string()),
"Should not have blocked_by_json"
);
}
#[test]
fn test_migration_blocked_cache_missing_issue_id() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2,
issue_type TEXT NOT NULL DEFAULT 'task',
assignee TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
content_hash TEXT,
external_ref TEXT,
ephemeral INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
due_at DATETIME,
defer_until DATETIME
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
PRIMARY KEY (issue_id, depends_on_id)
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
author TEXT NOT NULL,
text TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
old_value TEXT,
new_value TEXT,
comment TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE blocked_issues_cache (
id TEXT PRIMARY KEY,
blocked_by TEXT NOT NULL,
blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
",
)
.unwrap();
apply_schema(&conn).unwrap();
let cols: Vec<String> = conn
.query("PRAGMA table_info(blocked_issues_cache)")
.unwrap()
.iter()
.filter_map(|row| row.get(1).and_then(|v| v.as_text()).map(String::from))
.collect();
assert!(
cols.contains(&"issue_id".to_string()),
"issue_id column should exist after migration"
);
assert!(
cols.contains(&"blocked_by".to_string()),
"blocked_by column should exist after migration"
);
assert!(
cols.contains(&"blocked_at".to_string()),
"blocked_at column should exist after migration"
);
assert!(
!cols.contains(&"id".to_string()),
"legacy id column should be removed"
);
}
#[test]
fn test_migration_adds_missing_issue_columns() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);
",
)
.unwrap();
apply_schema(&conn).unwrap();
let cols: Vec<String> = conn
.query("PRAGMA table_info('issues')")
.unwrap()
.iter()
.filter_map(|row| row.get(1).and_then(|v| v.as_text()).map(String::from))
.collect();
let required = [
"description",
"design",
"acceptance_criteria",
"notes",
"owner",
"created_by",
"updated_at",
"source_repo",
"source_repo_path",
"compaction_level",
"sender",
"is_template",
];
for column in required {
assert!(
cols.contains(&column.to_string()),
"missing column {column}"
);
}
}
#[test]
fn test_rebuild_issues_table_errors_when_canonical_columns_are_missing() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
legacy_only TEXT
);
",
)
.unwrap();
let err = rebuild_issues_table(&conn).expect_err("rebuild should fail");
assert!(matches!(err, BeadsError::Config(_)));
assert!(
!table_exists(&conn, "issues_rebuild_tmp"),
"failed rebuild should roll back the temporary table"
);
}
#[test]
fn test_rebuild_issues_table_restores_foreign_keys_when_begin_fails() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("locked-rebuild.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("PRAGMA busy_timeout=0").unwrap();
apply_schema(&conn).unwrap();
let lock_conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
lock_conn.execute("PRAGMA busy_timeout=0").unwrap();
lock_conn.execute("BEGIN IMMEDIATE").unwrap();
assert!(foreign_keys_enabled(&conn).unwrap());
let err = rebuild_issues_table(&conn).expect_err("exclusive rebuild should hit busy lock");
assert!(
err.to_string().contains("busy") || err.to_string().contains("lock"),
"expected lock contention error, got {err}"
);
assert!(
foreign_keys_enabled(&conn).unwrap(),
"failed rebuild must restore foreign key enforcement"
);
lock_conn.execute("ROLLBACK").unwrap();
}
#[test]
fn test_migration_adds_missing_dependency_type() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);
CREATE TABLE dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
PRIMARY KEY (issue_id, depends_on_id)
);
",
)
.unwrap();
apply_schema(&conn).unwrap();
assert!(
conn.query("PRAGMA table_info('dependencies')")
.unwrap()
.iter()
.filter_map(|row| row.get(1).and_then(|v| v.as_text()).map(String::from))
.any(|col| col == "type"),
"missing dependency type column"
);
}
#[test]
fn test_migration_rebuilds_legacy_config_metadata_primary_keys() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
execute_batch(
&conn,
r"
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'new');
INSERT INTO metadata (key, value) VALUES ('project', 'new');
",
)
.unwrap();
apply_schema(&conn).unwrap();
let config_key_pk = conn
.query("PRAGMA table_info('config')")
.unwrap()
.iter()
.find(|row| row.get(1).and_then(SqliteValue::as_text) == Some("key"))
.and_then(|row| row.get(5).and_then(SqliteValue::as_integer))
.unwrap_or(0);
assert_eq!(config_key_pk, 0);
let metadata_key_pk = conn
.query("PRAGMA table_info('metadata')")
.unwrap()
.iter()
.find(|row| row.get(1).and_then(SqliteValue::as_text) == Some("key"))
.and_then(|row| row.get(5).and_then(SqliteValue::as_integer))
.unwrap_or(0);
assert_eq!(metadata_key_pk, 0);
let config_latest = conn
.query_row_with_params(
"SELECT value FROM config WHERE key = ?",
&[SqliteValue::from("issue_prefix")],
)
.unwrap();
assert_eq!(
config_latest.get(0).and_then(SqliteValue::as_text),
Some("new")
);
let metadata_latest = conn
.query_row_with_params(
"SELECT value FROM metadata WHERE key = ?",
&[SqliteValue::from("project")],
)
.unwrap();
assert_eq!(
metadata_latest.get(0).and_then(SqliteValue::as_text),
Some("new")
);
}
#[test]
fn test_runtime_schema_compatible_rejects_legacy_kv_primary_keys() {
let temp = tempfile::TempDir::new().unwrap();
let db_path = temp.path().join("legacy_kv.db");
{
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).expect("schema");
conn.execute("DROP INDEX IF EXISTS idx_config_key")
.expect("drop config index");
conn.execute("DROP TABLE config").expect("drop config");
conn.execute("CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.expect("recreate legacy config");
conn.execute("DROP INDEX IF EXISTS idx_metadata_key")
.expect("drop metadata index");
conn.execute("DROP TABLE metadata").expect("drop metadata");
conn.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.expect("recreate legacy metadata");
}
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
assert!(
!runtime_schema_compatible(&conn),
"legacy config/metadata primary keys should force the full repair path"
);
}
#[test]
fn test_active_list_query_plan_uses_composite_index() {
let conn = Connection::open(
tempfile::NamedTempFile::new()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
)
.unwrap();
apply_schema(&conn).expect("schema");
let plan_rows = conn
.query(
"EXPLAIN QUERY PLAN
SELECT id, priority, created_at
FROM issues
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL)
ORDER BY priority ASC, created_at DESC
LIMIT 1",
)
.expect("query plan");
let details: Vec<String> = plan_rows
.iter()
.filter_map(|row| row.get(3).and_then(|v| v.as_text()).map(String::from))
.collect();
let uses_index = details
.iter()
.any(|detail| detail.contains("idx_issues_list_active_order"));
let uses_scan = details.iter().any(|detail| detail.contains("SCAN"));
assert!(
uses_index || uses_scan,
"expected planner to use idx_issues_list_active_order or SCAN, got: {details:?}"
);
}
#[test]
fn test_split_normal_multi_statement() {
let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT); INSERT INTO a VALUES (1)";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 3);
assert_eq!(stmts[0], "CREATE TABLE a (id INT)");
assert_eq!(stmts[1], "CREATE TABLE b (id INT)");
assert_eq!(stmts[2], "INSERT INTO a VALUES (1)");
}
#[test]
fn test_split_semicolon_inside_single_quoted_string() {
let sql = "INSERT INTO t(v) VALUES('a;b'); SELECT 1";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "INSERT INTO t(v) VALUES('a;b')");
assert_eq!(stmts[1], "SELECT 1");
}
#[test]
fn test_split_semicolon_inside_double_quoted_identifier() {
let sql = r#"CREATE TABLE "weird;name" (id INT); SELECT 1"#;
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], r#"CREATE TABLE "weird;name" (id INT)"#);
assert_eq!(stmts[1], "SELECT 1");
}
#[test]
fn test_split_escaped_quotes_in_string() {
let sql = "INSERT INTO t(v) VALUES('it''s;here'); SELECT 2";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "INSERT INTO t(v) VALUES('it''s;here')");
assert_eq!(stmts[1], "SELECT 2");
}
#[test]
fn test_split_empty_statements() {
let sql = "SELECT 1;; ; SELECT 2";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "SELECT 1");
assert_eq!(stmts[1], "SELECT 2");
}
#[test]
fn test_split_trailing_semicolon() {
let sql = "SELECT 1; SELECT 2;";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "SELECT 1");
assert_eq!(stmts[1], "SELECT 2");
}
#[test]
fn test_split_line_comment_with_semicolon() {
let sql = "SELECT 1; -- this is a comment; not a split\nSELECT 2";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "SELECT 1");
assert_eq!(stmts[1], "-- this is a comment; not a split\nSELECT 2");
}
#[test]
fn test_split_block_comment_with_semicolon() {
let sql = "SELECT 1; /* comment; with; semicolons */ SELECT 2";
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0], "SELECT 1");
assert_eq!(stmts[1], "/* comment; with; semicolons */ SELECT 2");
}
#[test]
fn test_split_empty_input() {
assert!(split_sql_statements("").is_empty());
assert!(split_sql_statements(" ").is_empty());
assert!(split_sql_statements(" ; ; ").is_empty());
}
#[test]
fn test_split_single_statement_no_semicolon() {
let stmts = split_sql_statements("SELECT 42");
assert_eq!(stmts.len(), 1);
assert_eq!(stmts[0], "SELECT 42");
}
}