//! `SQLite` storage implementation.
use crate::error::{BeadsError, Result};
use crate::format::{IssueDetails, IssueWithDependencyMetadata, RollupSummary};
use crate::franken_sync::compat::{OpenFlags, open_with_flags};
use crate::franken_sync::{Connection, Row};
use crate::model::{
Comment, Dependency, DependencyType, Event, EventType, Issue, IssueType, Priority, Status,
};
use crate::storage::events::get_events;
use crate::storage::schema::CURRENT_SCHEMA_VERSION;
use crate::storage::schema::{
apply_runtime_compatible_schema, apply_schema, attest_runtime_schema_cookie, execute_batch,
record_runtime_schema_witness, runtime_schema_compatible, runtime_schema_witness_matches,
table_exists,
};
use crate::sync::{
FreshDatabaseReplacementWitness, METADATA_JSONL_CONTENT_HASH, METADATA_JSONL_MTIME,
METADATA_JSONL_SIZE, METADATA_LAST_EXPORT_TIME, METADATA_LAST_IMPORT_TIME,
METADATA_SYNC_MERGE_PENDING, METADATA_SYNC_MERGE_PENDING_LEGACY, SyncMergeIntent,
SyncMergePendingReceipt,
};
use crate::util::id::{normalize_prefix, parse_id};
use crate::validation::{CommentValidator, ISSUE_LABEL_MAX_COUNT, IssueValidator, LabelValidator};
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use fsqlite_error::FrankenError;
use fsqlite_types::SqliteValue;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
#[cfg(test)]
thread_local! {
static REPLACE_ATTACHED_DATABASE_AFTER_COMMIT: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
static CHANGE_USER_VERSION_AFTER_RUNTIME_COMPATIBILITY: std::cell::Cell<u32> =
const { std::cell::Cell::new(0) };
#[cfg(unix)]
static SWAP_NAMESPACE_SIDECAR_AFTER_OPEN: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
/// Test-only emulation of a mount that accepts `fchmod` and ignores it
/// (WSL drvfs without `metadata`, FAT/exFAT): the sidecar mode repair
/// reports success while the observed bits stay put (GitHub #491).
#[cfg(unix)]
static IGNORE_NAMESPACE_SIDECAR_CHMOD: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
/// Number of mutations between WAL checkpoint attempts.
const WAL_CHECKPOINT_INTERVAL: u32 = 50;
/// Per-statement busy spin timeout before SQLite returns SQLITE_BUSY.
///
/// Kept at 0 so `BEGIN IMMEDIATE` returns `SQLITE_BUSY` immediately and the
/// application-level retry loop (8 attempts with jittered exponential
/// backoff) remains the single bounded retry policy. Cross-process mutations
/// are serialized by the workspace `.write.lock` before reaching SQLite.
const DEFAULT_BUSY_TIMEOUT_MS: u64 = 0;
const SQLITE_VAR_LIMIT: usize = 900;
const REDUNDANT_LABEL_COVERAGE_MIN_CANDIDATES: usize = 8_192;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ListRelationMetadata {
pub(crate) labels: Vec<String>,
pub(crate) dependency_count: usize,
pub(crate) dependent_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangelogIssueRow {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) priority: Priority,
pub(crate) issue_type: IssueType,
pub(crate) created_at: DateTime<Utc>,
pub(crate) closed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DependencyCycleReport {
pub active_cycles: Vec<Vec<String>>,
pub archived_closed_cycles: Vec<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BulkDependencyInsert {
pub(crate) issue_id: String,
pub(crate) depends_on_id: String,
pub(crate) dep_type: String,
}
#[derive(Debug, Clone, Copy)]
struct CapacityTransition<'a> {
issue_id: &'a str,
from: Option<&'a str>,
to: &'a str,
}
#[derive(Debug)]
struct CapacityViolationEvidence<'a> {
transition: CapacityTransition<'a>,
kind: &'a str,
name: &'a str,
/// Scope partition the numbers were counted in (GitHub #384 phase 5).
scope: &'static str,
/// Partition key within a non-repository scope; `None` keeps the
/// pre-scope evidence shape byte-stable.
scope_key: Option<String>,
counting_mode: &'static str,
aggregate_parents_excluded: Option<u32>,
exempt: Option<u32>,
current: u32,
prospective: u32,
soft_limit: Option<u32>,
hard_limit: u32,
policy_path: String,
}
#[derive(Debug)]
struct CapacityWarningEvidence<'a> {
transition: CapacityTransition<'a>,
kind: &'a str,
name: &'a str,
/// Scope partition the numbers were counted in (GitHub #384 phase 5).
scope: &'static str,
/// Partition key within a non-repository scope; `None` keeps the
/// pre-scope evidence shape byte-stable.
scope_key: Option<String>,
counting_mode: &'static str,
aggregate_parents_excluded: Option<u32>,
exempt: Option<u32>,
current: u32,
prospective: u32,
soft_limit: u32,
hard_limit: Option<u32>,
policy_path: String,
}
#[derive(Debug, Clone)]
struct CapacityBatchTransition {
issue_id: String,
from: Option<String>,
to: String,
/// New issue type when the same mutation creates the issue or changes
/// its type; `None` means "use the type already stored". Only weighted
/// counting reads this.
issue_type: Option<String>,
/// Assignee currently stored on the issue (`None` for creates). Only
/// assignee-scope counting reads this (GitHub #384 phase 5).
current_assignee: Option<String>,
/// Assignee after this mutation commits. Only assignee-scope counting
/// reads this.
prospective_assignee: Option<String>,
}
/// The acting attribution one CLI mutation carries into capacity-scope
/// admission (GitHub #384 phase 5). A batch shares one acting context: the
/// actor comes from normal actor resolution and the rest is the
/// self-reported Tier-1 attribution. Scoped admission is cooperation, not
/// authentication — a missing key simply makes that scope inapplicable.
#[derive(Debug, Clone, Default)]
pub(crate) struct CapacityActingContext {
actor: Option<String>,
harness: Option<String>,
session: Option<String>,
}
impl CapacityActingContext {
pub(crate) fn new(actor: &str, attribution: &EventAttribution) -> Self {
fn normalized(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
Self {
actor: normalized(actor),
harness: attribution.harness.clone(),
session: attribution.session.clone(),
}
}
}
/// Current and prospective assignee of one enforced transition, for
/// assignee-scope counting (GitHub #384 phase 5).
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CapacityTransitionAssignee<'a> {
pub(crate) current: Option<&'a str>,
pub(crate) prospective: Option<&'a str>,
}
/// How one capacity scope partitions occupancy (GitHub #384 phase 5).
#[derive(Debug)]
enum CapacityScopeKeying {
/// One partition covering the whole repository.
Repository,
/// A single acting-attribution partition (actor/harness/session key).
Acting(String),
/// Partitioned by the issue's assignee.
Assignee,
/// Partitioned by the issue's root ancestor over parent-child edges.
Subtree,
}
/// One scoped status/group limit being enforced.
struct ScopedCapacityCheck<'a> {
kind_str: &'static str,
name: &'a str,
members: &'a [String],
limit: crate::close_policy::CapacityLimit,
scope: crate::close_policy::CapacityScopeKind,
keying: &'a CapacityScopeKeying,
policy_path: String,
}
/// Occupancy attribution loaded for one batch issue.
#[derive(Debug, Clone)]
struct CapacityOccupancyRow {
actor: Option<String>,
harness: Option<String>,
session: Option<String>,
}
/// Observed occupancy of one configured capacity (GitHub #384 phase 6).
///
/// Produced by [`SqliteStorage::capacity_snapshot`] for the observability
/// surfaces (`br stats`, `br coordination status`). One row per repository
/// capacity, plus one row per OCCUPIED partition of each scoped capacity.
#[derive(Debug, Clone)]
pub struct CapacitySnapshotRow {
/// `status` or `group`.
pub kind: String,
/// The status or group name.
pub name: String,
/// Scope partition dimension (`repository`, `actor`, ...).
pub scope: String,
/// Partition key within a non-repository scope.
pub scope_key: Option<String>,
/// Occupancy excluding exemptions (and hierarchy aggregates).
pub counted: u32,
/// Active issues excluded as hierarchy aggregates (`leaf_work`/`roots`).
pub aggregate_parents_excluded: Option<u32>,
/// Occupancy excluded by active, authorized exemptions.
pub exempt: Option<u32>,
/// Advisory threshold, if configured.
pub soft: Option<u32>,
/// Enforced threshold, if configured.
pub hard: Option<u32>,
/// Counting mode the numbers were computed under.
pub counting_mode: String,
/// Policy location of the limit.
pub policy_path: String,
}
/// Maximum partition rows reported per scoped capacity in a snapshot.
/// Mirrors the doctor `IdDelta` preview discipline: operators want the
/// first partitions, not an unbounded dump.
const CAPACITY_SNAPSHOT_PARTITION_LIMIT: usize = 50;
/// Root-ancestor index over parent-child edges for subtree capacity
/// scoping (GitHub #384 phase 5).
///
/// The root of an issue is the deterministic (lexicographically smallest)
/// terminal ancestor reachable over parent-child edges; an issue with no
/// parents — including one being created in this very mutation — is its own
/// root, and a pure ancestor cycle uses its smallest member. Loaded at most
/// once per enforcement call, inside the admission transaction.
struct CapacitySubtreeIndex {
parents_by_child: HashMap<String, Vec<String>>,
memo: std::cell::RefCell<HashMap<String, String>>,
}
impl CapacitySubtreeIndex {
fn load(conn: &Connection) -> Result<Self> {
let children_by_parent = SqliteStorage::load_local_parent_child_edges_impl(conn)?;
let parents_by_child = SqliteStorage::build_parents_by_child(&children_by_parent);
Ok(Self {
parents_by_child,
memo: std::cell::RefCell::new(HashMap::new()),
})
}
/// Deterministic subtree root for `issue_id`.
fn root_of(&self, issue_id: &str) -> String {
if let Some(root) = self.memo.borrow().get(issue_id) {
return root.clone();
}
let mut visited: HashSet<String> = HashSet::new();
let mut frontier: Vec<String> = vec![issue_id.to_string()];
let mut terminals: Vec<String> = Vec::new();
while let Some(current) = frontier.pop() {
if !visited.insert(current.clone()) {
continue;
}
match self.parents_by_child.get(¤t) {
Some(parents) if !parents.is_empty() => {
frontier.extend(parents.iter().cloned());
}
_ => terminals.push(current),
}
}
let root = terminals
.into_iter()
.min()
.or_else(|| visited.into_iter().min())
.unwrap_or_else(|| issue_id.to_string());
self.memo
.borrow_mut()
.insert(issue_id.to_string(), root.clone());
root
}
}
/// Current-vs-prospective occupancy for one capacity, as produced by
/// [`CapacityCountEngine::counts`].
#[derive(Debug, Clone, Copy)]
struct CapacityCountPair {
current: u32,
prospective: u32,
/// Prospective active issues excluded as hierarchy aggregates
/// (`leaf_work`/`roots` only).
aggregate_parents_excluded: Option<u32>,
/// Prospective occupancy excluded because the issues hold active,
/// authorized exemptions for this capacity (GitHub #384 phase 4).
/// `None` when no exemption affected the numbers.
exempt: Option<u32>,
}
/// Active, authorized capacity exemptions loaded once per enforcement call
/// (GitHub #384 phase 4).
///
/// Only exemptions that are unended, unexpired, and whose granting provider
/// is still listed in `workflow.capacity.exemptions.providers` participate:
/// removing a provider from policy silently withdraws its grants without
/// rewriting audit history. The exempted issues' statuses are captured here
/// so `all`-mode counting can subtract them without loading the full issue
/// set.
#[derive(Debug, Default)]
struct CapacityExemptionIndex {
/// `(kind, canonical capacity name)` -> issue ids holding an active,
/// authorized exemption for that capacity.
by_capacity: HashMap<(String, String), HashSet<String>>,
/// Actual status (trimmed, lowercased) of every exempted issue.
status_of: HashMap<String, String>,
}
impl CapacityExemptionIndex {
/// The exempted issue ids for one capacity identity, if any.
fn exempt_ids(&self, kind: &str, canonical_name: &str) -> Option<&HashSet<String>> {
if self.by_capacity.is_empty() {
return None;
}
self.by_capacity
.get(&(kind.to_string(), canonical_name.to_string()))
.filter(|ids| !ids.is_empty())
}
}
/// Transaction-scoped counting engine for workflow capacity (GitHub #384
/// phase 3).
///
/// One engine wraps one enforcement call: the batch's transitions are fixed
/// at construction and every configured capacity asks it for a
/// [`CapacityCountPair`]. Mode `all` keeps the phase-1 fast path (memoized
/// `COUNT(*)` plus per-transition ±1 arithmetic). The hierarchy modes load
/// the issue set and the parent-child graph once inside the same
/// transaction, condense strongly connected components so an imported
/// dependency cycle can never make active work invisible to capacity, and
/// evaluate the actual and the prospective status maps over that one
/// condensation.
struct CapacityCountEngine<'a> {
conn: &'a Connection,
counting: &'a crate::close_policy::CapacityCounting,
transitions: &'a [CapacityBatchTransition],
/// Active, authorized exemptions consulted per capacity identity.
exemptions: &'a CapacityExemptionIndex,
/// Mode `all`: memoized per-status `COUNT(*)` results.
status_counts: HashMap<String, u32>,
/// Hierarchy modes: lazily loaded graph snapshot.
hierarchy: Option<CapacityHierarchyState>,
/// Memoized results per capacity identity + canonical status set.
memo: HashMap<String, CapacityCountPair>,
}
impl<'a> CapacityCountEngine<'a> {
fn new(
conn: &'a Connection,
counting: &'a crate::close_policy::CapacityCounting,
transitions: &'a [CapacityBatchTransition],
exemptions: &'a CapacityExemptionIndex,
) -> Self {
Self {
conn,
counting,
transitions,
exemptions,
status_counts: HashMap::new(),
hierarchy: None,
memo: HashMap::new(),
}
}
const fn transitions(&self) -> &'a [CapacityBatchTransition] {
self.transitions
}
const fn mode_str(&self) -> &'static str {
self.counting.hierarchy.as_str()
}
/// Prospective status of one exempted issue: the batch transition's
/// target when the issue transitions in this batch, else its actual
/// status from the index snapshot.
fn exempt_prospective_status(&self, issue_id: &str) -> Option<String> {
if let Some(transition) = self
.transitions
.iter()
.find(|transition| transition.issue_id == issue_id)
{
return Some(transition.to.trim().to_lowercase());
}
self.exemptions.status_of.get(issue_id).cloned()
}
/// Occupancy for one capacity under mode `all` with exemptions applied:
/// exempted issues are subtracted from the current count and their
/// transitions never move the counted total.
fn all_mode_counts_with_exemptions(
&mut self,
canonical: &BTreeSet<String>,
statuses: &[String],
exempt_ids: &HashSet<String>,
) -> Result<CapacityCountPair> {
let raw_current = SqliteStorage::count_capacity_statuses_in_tx(
self.conn,
&mut self.status_counts,
statuses,
)?;
let exempt_current = u32::try_from(
exempt_ids
.iter()
.filter(|id| {
self.exemptions
.status_of
.get(id.as_str())
.is_some_and(|status| canonical.contains(status.as_str()))
})
.count(),
)
.map_err(|_| BeadsError::internal("workflow capacity exempt count overflowed u32"))?;
let current = raw_current.saturating_sub(exempt_current);
let mut prospective = i64::from(current);
for transition in self.transitions {
if exempt_ids.contains(&transition.issue_id) {
continue;
}
if SqliteStorage::transition_enters_capacity(transition, statuses) {
prospective += 1;
} else if SqliteStorage::transition_drains_capacity(transition, statuses) {
prospective -= 1;
}
}
let prospective = u32::try_from(prospective).map_err(|_| {
BeadsError::internal(format!(
"invalid prospective workflow capacity count {prospective}"
))
})?;
let exempt_prospective = u32::try_from(
exempt_ids
.iter()
.filter(|id| {
self.exempt_prospective_status(id)
.is_some_and(|status| canonical.contains(status.as_str()))
})
.count(),
)
.map_err(|_| BeadsError::internal("workflow capacity exempt count overflowed u32"))?;
Ok(CapacityCountPair {
current,
prospective,
aggregate_parents_excluded: None,
exempt: (exempt_prospective > 0).then_some(exempt_prospective),
})
}
/// Current and prospective occupancy for one named capacity's status
/// set. `kind`/`name` identify the capacity so issue-specific
/// exemptions scoped to it can be applied.
fn counts(&mut self, kind: &str, name: &str, statuses: &[String]) -> Result<CapacityCountPair> {
use crate::close_policy::CapacityCountingMode;
let canonical: BTreeSet<String> = statuses
.iter()
.map(|status| status.trim().to_lowercase())
.filter(|status| !status.is_empty())
.collect();
let canonical_name = name.trim().to_lowercase();
let key = format!(
"{kind}\u{1}{canonical_name}\u{1}{}",
canonical.iter().cloned().collect::<Vec<_>>().join("\u{1}")
);
if let Some(pair) = self.memo.get(&key) {
return Ok(*pair);
}
let exempt_ids = self
.exemptions
.exempt_ids(kind, &canonical_name)
.cloned()
.unwrap_or_default();
if matches!(self.counting.hierarchy, CapacityCountingMode::All) {
let pair = if exempt_ids.is_empty() {
let current = SqliteStorage::count_capacity_statuses_in_tx(
self.conn,
&mut self.status_counts,
statuses,
)?;
let prospective = SqliteStorage::batch_prospective_capacity_count(
current,
statuses,
self.transitions,
)?;
CapacityCountPair {
current,
prospective,
aggregate_parents_excluded: None,
exempt: None,
}
} else {
self.all_mode_counts_with_exemptions(&canonical, statuses, &exempt_ids)?
};
self.memo.insert(key, pair);
return Ok(pair);
}
if self.hierarchy.is_none() {
self.hierarchy = Some(CapacityHierarchyState::load(self.conn, self.transitions)?);
}
let Some(state) = self.hierarchy.as_ref() else {
return Err(BeadsError::internal(
"capacity hierarchy state missing after load",
));
};
let pair = match self.counting.hierarchy {
CapacityCountingMode::All => {
return Err(BeadsError::internal(
"capacity counting mode 'all' reached the hierarchy path",
));
}
CapacityCountingMode::Weighted => {
let (current, _) = state.weighted_count(
&canonical,
&state.actual_status,
&state.actual_type,
&self.counting.weights,
&exempt_ids,
)?;
let (prospective, exempt_prospective) = state.weighted_count(
&canonical,
&state.prospective_status,
&state.prospective_type,
&self.counting.weights,
&exempt_ids,
)?;
CapacityCountPair {
current,
prospective,
aggregate_parents_excluded: None,
exempt: (exempt_prospective > 0).then_some(exempt_prospective),
}
}
mode @ (CapacityCountingMode::LeafWork | CapacityCountingMode::Roots) => {
let (current, _, _) =
state.hierarchy_count(&canonical, &state.actual_status, mode, &exempt_ids)?;
let (prospective, excluded, exempt_prospective) = state.hierarchy_count(
&canonical,
&state.prospective_status,
mode,
&exempt_ids,
)?;
CapacityCountPair {
current,
prospective,
aggregate_parents_excluded: Some(excluded),
exempt: (exempt_prospective > 0).then_some(exempt_prospective),
}
}
};
self.memo.insert(key, pair);
Ok(pair)
}
}
/// Issue-graph snapshot used by the hierarchy-aware counting modes.
///
/// Nodes are every issue in the repository plus every parent-child edge
/// endpoint plus every transition subject (so an issue being created in
/// this same transaction participates as an isolated node). Missing nodes
/// carry an empty status and are therefore never active.
struct CapacityHierarchyState {
ids: Vec<String>,
actual_status: Vec<String>,
prospective_status: Vec<String>,
actual_type: Vec<String>,
prospective_type: Vec<String>,
/// Strongly connected component members, in Tarjan emission order: a
/// component is emitted only after every component it can reach, so
/// descendant components always precede their ancestors.
comp_members: Vec<Vec<usize>>,
/// Deduplicated condensation edges, parent component -> child component.
comp_children: Vec<Vec<usize>>,
}
impl CapacityHierarchyState {
fn intern(
id: &str,
ids: &mut Vec<String>,
index_of: &mut HashMap<String, usize>,
actual_status: &mut Vec<String>,
actual_type: &mut Vec<String>,
) -> usize {
if let Some(&index) = index_of.get(id) {
return index;
}
let index = ids.len();
index_of.insert(id.to_string(), index);
ids.push(id.to_string());
actual_status.push(String::new());
actual_type.push(String::new());
index
}
fn load(conn: &Connection, transitions: &[CapacityBatchTransition]) -> Result<Self> {
let mut ids = Vec::new();
let mut index_of: HashMap<String, usize> = HashMap::new();
let mut actual_status = Vec::new();
let mut actual_type = Vec::new();
let rows = conn.query("SELECT id, status, issue_type FROM issues")?;
for row in &rows {
let Some(id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
if id.is_empty() {
continue;
}
let status = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.trim()
.to_lowercase();
let issue_type = row
.get(2)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.trim()
.to_lowercase();
let index = Self::intern(
id,
&mut ids,
&mut index_of,
&mut actual_status,
&mut actual_type,
);
actual_status[index] = status;
actual_type[index] = issue_type;
}
let edge_map = SqliteStorage::load_local_parent_child_edges_impl(conn)?;
for (parent, children) in &edge_map {
Self::intern(
parent,
&mut ids,
&mut index_of,
&mut actual_status,
&mut actual_type,
);
for child in children {
Self::intern(
child,
&mut ids,
&mut index_of,
&mut actual_status,
&mut actual_type,
);
}
}
for transition in transitions {
Self::intern(
&transition.issue_id,
&mut ids,
&mut index_of,
&mut actual_status,
&mut actual_type,
);
}
let mut children: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
for (parent, kids) in &edge_map {
let Some(&parent_index) = index_of.get(parent.as_str()) else {
continue;
};
for kid in kids {
let Some(&kid_index) = index_of.get(kid.as_str()) else {
continue;
};
if parent_index != kid_index {
children[parent_index].push(kid_index);
}
}
}
let mut prospective_status = actual_status.clone();
let mut prospective_type = actual_type.clone();
for transition in transitions {
let Some(&index) = index_of.get(transition.issue_id.as_str()) else {
continue;
};
prospective_status[index] = transition.to.trim().to_lowercase();
if let Some(issue_type) = &transition.issue_type {
prospective_type[index] = issue_type.trim().to_lowercase();
}
}
let (comp_members, comp_children) = Self::condense(&children);
Ok(Self {
ids,
actual_status,
prospective_status,
actual_type,
prospective_type,
comp_members,
comp_children,
})
}
/// Iterative Tarjan strongly-connected-components condensation.
///
/// The dependency graph forbids parent-child cycles at mutation time,
/// but imported or hand-edited data can still contain them; treating a
/// cycle as one component means its active members always count instead
/// of silently excluding each other.
fn condense(children: &[Vec<usize>]) -> (Vec<Vec<usize>>, Vec<Vec<usize>>) {
let node_count = children.len();
let mut next_index = 0usize;
let mut indices = vec![usize::MAX; node_count];
let mut lowlink = vec![0usize; node_count];
let mut on_stack = vec![false; node_count];
let mut stack: Vec<usize> = Vec::new();
let mut comp_of = vec![usize::MAX; node_count];
let mut comp_members: Vec<Vec<usize>> = Vec::new();
let mut call_stack: Vec<(usize, usize)> = Vec::new();
for start in 0..node_count {
if indices[start] != usize::MAX {
continue;
}
call_stack.push((start, 0));
while let Some(frame) = call_stack.last_mut() {
let node = frame.0;
if indices[node] == usize::MAX {
indices[node] = next_index;
lowlink[node] = next_index;
next_index += 1;
stack.push(node);
on_stack[node] = true;
}
if frame.1 < children[node].len() {
let child = children[node][frame.1];
frame.1 += 1;
if indices[child] == usize::MAX {
call_stack.push((child, 0));
} else if on_stack[child] {
lowlink[node] = lowlink[node].min(indices[child]);
}
} else {
call_stack.pop();
if let Some(&(parent, _)) = call_stack.last() {
lowlink[parent] = lowlink[parent].min(lowlink[node]);
}
if lowlink[node] == indices[node] {
let comp_index = comp_members.len();
let mut members = Vec::new();
while let Some(member) = stack.pop() {
on_stack[member] = false;
comp_of[member] = comp_index;
members.push(member);
if member == node {
break;
}
}
comp_members.push(members);
}
}
}
}
let mut comp_children: Vec<Vec<usize>> = vec![Vec::new(); comp_members.len()];
let mut seen: HashSet<(usize, usize)> = HashSet::new();
for node in 0..node_count {
for &child in &children[node] {
let parent_comp = comp_of[node];
let child_comp = comp_of[child];
if parent_comp != child_comp && seen.insert((parent_comp, child_comp)) {
comp_children[parent_comp].push(child_comp);
}
}
}
(comp_members, comp_children)
}
/// Count occupancy for one status set under `leaf_work` or `roots`.
///
/// Returns `(counted, aggregate_excluded, exempt)`. `aggregate_excluded`
/// is the number of active issues that did not count because a
/// relative in the same capacity already covers their work stream.
/// `exempt` is the number of active issues in counting components that
/// were excluded by an issue-specific exemption (GitHub #384 phase 4).
/// Exempted issues stay *active* for suppression purposes — an
/// exemption can therefore never raise a count, only lower it.
fn hierarchy_count(
&self,
statuses: &BTreeSet<String>,
status_of: &[String],
mode: crate::close_policy::CapacityCountingMode,
exempt_ids: &HashSet<String>,
) -> Result<(u32, u32, u32)> {
use crate::close_policy::CapacityCountingMode;
let comp_count = self.comp_members.len();
let mut active_members = vec![0u64; comp_count];
let mut exempt_members = vec![0u64; comp_count];
let mut total_active = 0u64;
for (comp, members) in self.comp_members.iter().enumerate() {
for &member in members {
if statuses.contains(status_of[member].as_str()) {
active_members[comp] += 1;
total_active += 1;
if exempt_ids.contains(self.ids[member].as_str()) {
exempt_members[comp] += 1;
}
}
}
}
let (counted, exempt): (u64, u64) = match mode {
CapacityCountingMode::LeafWork => {
// Emission order puts descendant components first, so every
// child flag is final before its parent is examined.
let mut has_active_descendant = vec![false; comp_count];
let mut counted = 0u64;
let mut exempt = 0u64;
for comp in 0..comp_count {
for &child in &self.comp_children[comp] {
if active_members[child] > 0 || has_active_descendant[child] {
has_active_descendant[comp] = true;
break;
}
}
if active_members[comp] > 0 && !has_active_descendant[comp] {
counted += active_members[comp] - exempt_members[comp];
exempt += exempt_members[comp];
}
}
(counted, exempt)
}
CapacityCountingMode::Roots => {
// Reverse emission order puts ancestor components first, so
// each component's flag is final before it propagates down.
let mut has_active_ancestor = vec![false; comp_count];
for comp in (0..comp_count).rev() {
if active_members[comp] > 0 || has_active_ancestor[comp] {
for &child in &self.comp_children[comp] {
has_active_ancestor[child] = true;
}
}
}
let mut counted = 0u64;
let mut exempt = 0u64;
for comp in 0..comp_count {
if active_members[comp] > 0 && !has_active_ancestor[comp] {
counted += active_members[comp] - exempt_members[comp];
exempt += exempt_members[comp];
}
}
(counted, exempt)
}
CapacityCountingMode::All | CapacityCountingMode::Weighted => {
return Err(BeadsError::internal(
"hierarchy_count called with a non-hierarchy counting mode",
));
}
};
let counted = u32::try_from(counted).map_err(|_| {
BeadsError::internal("workflow capacity hierarchy count overflowed u32")
})?;
let exempt = u32::try_from(exempt)
.map_err(|_| BeadsError::internal("workflow capacity exempt count overflowed u32"))?;
let excluded =
u32::try_from(total_active.saturating_sub(u64::from(counted) + u64::from(exempt)))
.map_err(|_| {
BeadsError::internal("workflow capacity aggregate exclusion overflowed u32")
})?;
Ok((counted, excluded, exempt))
}
/// Sum explicit weights over the active issues of one status set,
/// splitting exempted issues' weights into a separate total.
fn weighted_count(
&self,
statuses: &BTreeSet<String>,
status_of: &[String],
type_of: &[String],
weights: &crate::close_policy::CapacityWeights,
exempt_ids: &HashSet<String>,
) -> Result<(u32, u32)> {
let mut total = 0u64;
let mut exempt = 0u64;
for index in 0..self.ids.len() {
if statuses.contains(status_of[index].as_str()) {
let weight = u64::from(weights.weight_for(&self.ids[index], &type_of[index]));
if exempt_ids.contains(self.ids[index].as_str()) {
exempt += weight;
} else {
total += weight;
}
}
}
let total = u32::try_from(total)
.map_err(|_| BeadsError::internal("workflow capacity weighted count overflowed u32"))?;
let exempt = u32::try_from(exempt).map_err(|_| {
BeadsError::internal("workflow capacity exempt weight total overflowed u32")
})?;
Ok((total, exempt))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BlockedCacheProjectionHealth {
pub(crate) parity_status: String,
pub(crate) direct_blocked_rows: Option<usize>,
pub(crate) cached_missing_rows: Option<usize>,
pub(crate) cached_extra_rows: Option<usize>,
pub(crate) cached_mismatched_rows: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReadyProjectionHealth {
pub(crate) parity_status: String,
pub(crate) cached_ready_rows: Option<usize>,
pub(crate) direct_ready_rows: Option<usize>,
pub(crate) cached_ready_missing_rows: Option<usize>,
pub(crate) cached_ready_extra_rows: Option<usize>,
}
impl BlockedCacheProjectionHealth {
fn unavailable(direct_blocked_rows: Option<usize>) -> Self {
Self {
parity_status: "unavailable".to_string(),
direct_blocked_rows,
cached_missing_rows: None,
cached_extra_rows: None,
cached_mismatched_rows: None,
}
}
pub(crate) fn has_mismatch(&self) -> bool {
[
self.cached_missing_rows,
self.cached_extra_rows,
self.cached_mismatched_rows,
]
.into_iter()
.flatten()
.any(|count| count > 0)
}
}
impl ReadyProjectionHealth {
fn unavailable(cached_ready_rows: Option<usize>, direct_ready_rows: Option<usize>) -> Self {
Self {
parity_status: "unavailable".to_string(),
cached_ready_rows,
direct_ready_rows,
cached_ready_missing_rows: None,
cached_ready_extra_rows: None,
}
}
pub(crate) fn has_mismatch(&self) -> bool {
[self.cached_ready_missing_rows, self.cached_ready_extra_rows]
.into_iter()
.flatten()
.any(|count| count > 0)
}
}
fn unique_label_refs(labels: &[String]) -> Vec<&String> {
let mut unique_labels = Vec::with_capacity(labels.len());
let mut seen_labels = HashSet::with_capacity(labels.len());
for label in labels {
if seen_labels.insert(label.as_str()) {
unique_labels.push(label);
}
}
unique_labels
}
fn append_label_membership_filters(
sql: &mut String,
params: &mut Vec<SqliteValue>,
labels_and: &[String],
labels_or: &[String],
) {
let unique_labels_and = unique_label_refs(labels_and);
match unique_labels_and.as_slice() {
[] => {}
[label] => {
sql.push_str(" AND issues.id IN (SELECT issue_id FROM labels WHERE label = ?)");
params.push(SqliteValue::from(label.as_str()));
}
_ => {
let placeholders: Vec<String> =
unique_labels_and.iter().map(|_| "?".to_string()).collect();
let _ = write!(
sql,
" AND issues.id IN (
SELECT issue_id
FROM labels
WHERE label IN ({})
GROUP BY issue_id
HAVING COUNT(DISTINCT label) = ?
)",
placeholders.join(",")
);
for label in &unique_labels_and {
params.push(SqliteValue::from(label.as_str()));
}
params.push(SqliteValue::from(
i64::try_from(unique_labels_and.len()).unwrap_or(i64::MAX),
));
}
}
if !labels_or.is_empty() {
let placeholders: Vec<String> = labels_or.iter().map(|_| "?".to_string()).collect();
let _ = write!(
sql,
" AND issues.id IN (SELECT issue_id FROM labels WHERE label IN ({}))",
placeholders.join(",")
);
for label in labels_or {
params.push(SqliteValue::from(label.as_str()));
}
}
}
fn append_issue_source_with_label_and_joins(
sql: &mut String,
params: &mut Vec<SqliteValue>,
labels_and: &[String],
) {
let unique_labels_and = unique_label_refs(labels_and);
let Some((first_label, remaining_labels)) = unique_labels_and.split_first() else {
sql.push_str(" FROM issues");
return;
};
sql.push_str(
" FROM labels AS labels_and_1
JOIN issues
ON issues.id = labels_and_1.issue_id
AND labels_and_1.label = ?",
);
params.push(SqliteValue::from(first_label.as_str()));
for (index, label) in remaining_labels.iter().enumerate() {
let alias_number = index + 2;
let _ = write!(
sql,
" JOIN labels AS labels_and_{alias_number}
ON labels_and_{alias_number}.issue_id = issues.id
AND labels_and_{alias_number}.label = ?"
);
params.push(SqliteValue::from(label.as_str()));
}
}
fn append_issue_id_membership_filter(
sql: &mut String,
params: &mut Vec<SqliteValue>,
issue_ids: &[String],
) {
sql.push_str(" AND (");
for (index, chunk) in issue_ids.chunks(SQLITE_VAR_LIMIT).enumerate() {
if index > 0 {
sql.push_str(" OR ");
}
let placeholders: Vec<String> = chunk.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, "id IN ({})", placeholders.join(","));
for issue_id in chunk {
params.push(SqliteValue::from(issue_id.as_str()));
}
}
sql.push(')');
}
fn append_label_or_membership_exists(
sql: &mut String,
params: &mut Vec<SqliteValue>,
labels_or: &[String],
) {
if labels_or.is_empty() {
return;
}
let placeholders: Vec<String> = labels_or.iter().map(|_| "?".to_string()).collect();
let _ = write!(
sql,
" AND EXISTS (
SELECT 1
FROM labels
WHERE labels.issue_id = issues.id
AND labels.label IN ({})
)",
placeholders.join(",")
);
for label in labels_or {
params.push(SqliteValue::from(label.as_str()));
}
}
// `fsqlite` starts returning false PRIMARY KEY conflicts when we rewrite
// existing `export_hashes` rows with a single multi-values INSERT. Batch the
// DELETE side for efficiency, but re-insert one row at a time for correctness.
const EXPORT_HASH_CHUNK_SIZE: usize = 32;
// `fsqlite` can surface the same false primary-key conflict when an existing
// blocked-cache population is rewritten via a large multi-values INSERT. Keep
// the delete batched/full-table, but re-insert rows individually.
const BLOCKED_CACHE_DELETE_CHUNK_SIZE: usize = 400;
const DIRTY_ISSUE_CHUNK_SIZE: usize = 900;
const BLOCKS_DEP_EDGE_FILTER_LIMIT: usize = 400;
const IMPORT_DEPENDENCY_CHUNK_SIZE: usize = 140;
const DEPENDENCY_TRAVERSAL_MAX_DEPTH: usize = 500;
const BLOCKED_CACHE_STATE_KEY: &str = "blocked_cache_state";
const BLOCKED_CACHE_STATE_STALE: &str = "stale";
/// Annotation suffix appended to a blocker ref when an epic is "blocked" purely
/// because it still has an open child (e.g. `bd-42:child-open`).
///
/// This is a **close-ordering** marker — an epic should not be *closed* while it
/// has open children — and must never prevent the epic from being *started*
/// (claimed / moved to `in_progress`). The producer queries embed this suffix in
/// `blocked_issues_cache`; [`SqliteStorage::get_start_blockers`] filters it back
/// out so claim/start guards ignore it (#315).
const CHILD_OPEN_BLOCKER_SUFFIX: &str = ":child-open";
/// Suffix marking a blocker ref produced by propagating an *already-blocked*
/// parent down onto its children (e.g. `bd-3n73:parent-blocked`).
///
/// This is an advisory **readiness** marker used to rank a child of a blocked
/// epic below truly-ready work in `br ready`. It is hierarchy, not a
/// prerequisite edge from the parent to the child, so it must never act as a
/// *hard* gate: a finished child must be *closable* (#355) and an actionable
/// child must be *claimable / startable* (#357) even while its parent epic is
/// blocked. The producer
/// ([`SqliteStorage::propagate_blocked_parents`]) embeds this suffix in
/// `blocked_issues_cache`; both [`SqliteStorage::get_close_blockers`] and
/// [`SqliteStorage::get_start_blockers`] filter it back out so neither the close
/// gate nor the start/claim gate treats it as a real blocker.
const PARENT_BLOCKED_SUFFIX: &str = ":parent-blocked";
const NEEDS_FLUSH_KEY: &str = "needs_flush";
/// Metadata key holding a sorted JSON array of issue IDs that were
/// intentionally hard-deleted (purged) from the database but may still be
/// present in the on-disk JSONL. The exporter's stale-database data-loss
/// guard subtracts these IDs from its "would lose issues" computation so a
/// post-purge flush can legitimately write a JSONL with fewer issues without
/// requiring blanket `force` semantics (#405). Cleared on successful export
/// finalization.
pub(crate) const PURGED_IDS_PENDING_EXPORT_KEY: &str = "purged_ids_pending_export";
const METADATA_EMPTY_VALUE: &str = "";
const METADATA_FALSE_VALUE: &str = "false";
const KNOWN_METADATA_DEFAULTS: [(&str, &str); 7] = [
(BLOCKED_CACHE_STATE_KEY, METADATA_EMPTY_VALUE),
(NEEDS_FLUSH_KEY, METADATA_FALSE_VALUE),
(METADATA_JSONL_CONTENT_HASH, METADATA_EMPTY_VALUE),
(METADATA_JSONL_MTIME, METADATA_EMPTY_VALUE),
(METADATA_JSONL_SIZE, METADATA_EMPTY_VALUE),
(METADATA_LAST_EXPORT_TIME, METADATA_EMPTY_VALUE),
(METADATA_LAST_IMPORT_TIME, METADATA_EMPTY_VALUE),
];
/// Coherent classification of the two durable pending-sync-merge metadata keys.
///
/// `Absent` is the only state that permits an unrelated automatic mutation.
/// Every other variant is a durable or ambiguous saga state that only the
/// explicit `br sync --merge` recovery path may advance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PendingSyncMergeInspection {
Absent,
Valid(Box<SyncMergePendingReceipt>),
Legacy {
metadata_key: String,
row_count: usize,
diagnostic: String,
},
Malformed {
metadata_key: String,
diagnostic: String,
},
}
impl PendingSyncMergeInspection {
#[must_use]
pub(crate) const fn permits_automatic_mutation(&self) -> bool {
matches!(self, Self::Absent)
}
#[must_use]
pub(crate) fn diagnostic(&self) -> String {
match self {
Self::Absent => "No pending sync merge receipt is present".to_string(),
Self::Valid(receipt) => format!(
"Pending sync merge receipt {} is in {:?} phase",
receipt.receipt_id, receipt.phase
),
Self::Legacy { diagnostic, .. } | Self::Malformed { diagnostic, .. } => {
diagnostic.clone()
}
}
}
}
/// Classify exact raw rows from both pending-sync-merge metadata keys.
///
/// This function deliberately receives every matching row, including SQL
/// `NULL`, rather than going through `get_metadata()`. That prevents a
/// duplicate, null, empty, legacy, or malformed receipt from being mistaken
/// for the safe `Absent` state.
#[allow(clippy::too_many_lines)]
pub(crate) fn classify_pending_sync_merge_rows(
current_rows: &[Option<String>],
legacy_rows: &[Option<String>],
) -> PendingSyncMergeInspection {
if !legacy_rows.is_empty() && !current_rows.is_empty() {
return PendingSyncMergeInspection::Malformed {
metadata_key: format!(
"{METADATA_SYNC_MERGE_PENDING_LEGACY},{METADATA_SYNC_MERGE_PENDING}"
),
diagnostic: format!(
"Found both legacy ({}) and current ({}) pending sync-merge metadata row(s); competing receipts are ambiguous and automatic mutation is disabled",
legacy_rows.len(),
current_rows.len()
),
};
}
if legacy_rows.len() > 1 {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING_LEGACY.to_string(),
diagnostic: format!(
"Found {} legacy pending sync-merge metadata rows; duplicate receipts are ambiguous and automatic mutation is disabled",
legacy_rows.len()
),
};
}
if let [legacy] = legacy_rows {
if legacy
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING_LEGACY.to_string(),
diagnostic:
"Legacy pending sync-merge metadata is NULL or empty; automatic mutation is disabled"
.to_string(),
};
}
return PendingSyncMergeInspection::Legacy {
metadata_key: METADATA_SYNC_MERGE_PENDING_LEGACY.to_string(),
row_count: 1,
diagnostic:
"Legacy pending sync-merge state requires explicit `br sync --merge` reconciliation"
.to_string(),
};
}
let [serialized] = current_rows else {
return if current_rows.is_empty() {
PendingSyncMergeInspection::Absent
} else {
PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic: format!(
"Found {} current pending sync-merge metadata rows; duplicate receipts are ambiguous and automatic mutation is disabled",
current_rows.len()
),
}
};
};
let Some(serialized) = serialized
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic:
"Current pending sync-merge metadata is NULL or empty; automatic mutation is disabled"
.to_string(),
};
};
let receipt = match serde_json::from_str::<SyncMergePendingReceipt>(serialized) {
Ok(receipt) => receipt,
Err(error) => {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic: format!(
"Current pending sync-merge receipt is not valid JSON for schema v2: {error}"
),
};
}
};
if let Err(error) = receipt.validate() {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic: format!(
"Current pending sync-merge receipt failed schema or intent-hash validation: {error}"
),
};
}
let canonical = match serde_json::to_string(&receipt) {
Ok(canonical) => canonical,
Err(error) => {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic: format!(
"Current pending sync-merge receipt could not be canonically serialized: {error}"
),
};
}
};
if canonical != serialized {
return PendingSyncMergeInspection::Malformed {
metadata_key: METADATA_SYNC_MERGE_PENDING.to_string(),
diagnostic:
"Current pending sync-merge receipt is noncanonical or contains unrecognized fields; exact compare-and-swap recovery would be ambiguous"
.to_string(),
};
}
PendingSyncMergeInspection::Valid(Box::new(receipt))
}
/// DB-only auxiliary/history tables that a JSONL rebuild would otherwise
/// discard (GitHub #471). These never travel through the JSONL export, so a
/// `br doctor --repair` rebuild must carry them across explicitly.
pub(crate) const AUXILIARY_HISTORY_TABLES: &[&str] = &[
"events",
"gate_results",
"gate_result_history",
"close_metadata",
"capacity_occupancy",
"capacity_exemptions",
"capacity_exemption_history",
];
/// Raw rows captured from one auxiliary/history table before a rebuild.
#[derive(Debug, Clone)]
pub(crate) struct PreservedTableRows {
pub(crate) table: String,
pub(crate) columns: Vec<String>,
pub(crate) rows: Vec<Vec<SqliteValue>>,
}
/// Snapshot of every auxiliary/history table plus per-table capture failures
/// (a corrupt source page must not abort preservation of the other tables).
#[derive(Debug, Clone, Default)]
pub(crate) struct AuxiliaryHistorySnapshot {
pub(crate) tables: Vec<PreservedTableRows>,
pub(crate) failures: Vec<String>,
}
impl AuxiliaryHistorySnapshot {
pub(crate) fn row_count(&self) -> usize {
self.tables.iter().map(|table| table.rows.len()).sum()
}
}
/// SQLite-based storage backend.
#[derive(Debug)]
pub struct SqliteStorage {
conn: Connection,
/// Owned advisory capability for writable persistent storage. Keeping it
/// on the storage itself prevents callers from moving the public storage
/// handle out of a higher-level context and accidentally releasing the
/// database-family authority first.
write_authority: Option<Arc<crate::sync::DatabaseFamilyWriteLock>>,
/// Track mutations to trigger periodic WAL checkpoints.
mutation_count: u32,
/// Shared registration as an opener of the persistent database. Held for
/// the lifetime of this handle so any br process can tell whether it is
/// the sole opener before running a WAL checkpoint (see
/// [`crate::sync::DatabaseOpenerLease`]). `None` for ephemeral databases.
opener_lease: Option<crate::sync::DatabaseOpenerLease>,
/// When set, this storage owns an ephemeral on-disk temp database (created
/// by [`SqliteStorage::open_memory`]) that must be unlinked — together with
/// its WAL/SHM/journal sidecars — when the connection is dropped. FrankenSQLite
/// requires real file paths for WAL and schema operations, so the "in-memory"
/// path is backed by a real temp file rather than `:memory:`; without this
/// cleanup the files accumulate in `TMPDIR` (#299). `None` for persistent
/// databases, which must never be deleted on drop.
temp_db_path: Option<PathBuf>,
/// Tier 1 attribution to stamp onto the audit events of the NEXT mutation
/// (issue #312, Layer 3 capture-only). Set via
/// [`SqliteStorage::set_pending_event_attribution`] immediately before a
/// `create`/`update` call so the command layer can attach self-reported
/// agent identity without threading it through every storage signature.
/// Consumed by exactly one COMMITTING `mutate()` (cleared only after the
/// write transaction commits, so a JSONL-recovery retry can still stamp it),
/// or cleared by any staged-mutation entry point that returns without
/// committing (e.g. an empty-`updates` no-op). It therefore never leaks into
/// an unrelated subsequent operation. Capture-only — never used for gating.
pending_event_attribution: Option<EventAttribution>,
/// Repository-level workflow capacity policy loaded by the command/config
/// layer. Direct storage users default to an inactive policy and may opt
/// in with [`SqliteStorage::set_workflow_capacity_policy`]. Enforcement
/// happens inside the same `BEGIN IMMEDIATE` transaction as the status
/// mutation, closing the count-then-transition race from GitHub #384.
workflow_capacity_policy: crate::close_policy::CapacityPolicy,
/// Strict transition policy loaded by the command/config layer. This owns
/// attempt-scoped gates and transition-required fields; enforcement occurs
/// inside the same write transaction as the status change (GitHub #388).
workflow_transition_policy: crate::close_policy::Workflow,
/// Advisory capacity evidence produced by the most recently committed
/// mutation. Cleared at the start of every mutation and consumed by the
/// command layer immediately after success, so warnings cannot leak into
/// an unrelated command.
last_capacity_warnings: Vec<crate::close_policy::WorkflowCapacityWarning>,
}
/// Outcome of [`SqliteStorage::admit_checkpoint`].
enum CheckpointAdmission {
/// This process is the only opener; the exclusive opener hold (if the
/// database is persistent) must be returned through
/// [`SqliteStorage::release_checkpoint_admission`].
Sole(Option<std::fs::File>),
/// Another process has the database open; no checkpoint may run.
PeersPresent,
}
/// Context for a mutation operation, tracking side effects.
/// Tier 1 attribution captured on status-mutating commands (issue #312,
/// Layer 3). Self-reported agent/harness/model/session identity recorded
/// onto emitted audit events and the capacity-occupancy row. Since GitHub
/// #384 phase 5, the harness/session values (plus the resolved actor) also
/// key OPTIONAL capacity scopes — cooperative admission control, not
/// authentication: attribution stays self-reported and a missing value
/// simply makes the corresponding scope inapplicable. Empty/whitespace-only
/// inputs are coerced to `None` so absent attribution never produces
/// blank-string noise in the audit log.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EventAttribution {
pub agent_name: Option<String>,
pub harness: Option<String>,
pub model: Option<String>,
/// Self-reported session identity (`BR_SESSION`). Feeds the capacity
/// occupancy record and the `session` capacity scope; deliberately NOT
/// written to the events table, whose schema is shared with classic bd.
pub session: Option<String>,
}
impl EventAttribution {
/// Build attribution from raw CLI/env inputs, normalizing empty or
/// whitespace-only values to `None`.
#[must_use]
pub fn new(
agent_name: Option<&str>,
harness: Option<&str>,
model: Option<&str>,
session: Option<&str>,
) -> Self {
let norm = |v: Option<&str>| {
v.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
};
Self {
agent_name: norm(agent_name),
harness: norm(harness),
model: norm(model),
session: norm(session),
}
}
/// True when no attribution value was supplied.
#[must_use]
pub fn is_empty(&self) -> bool {
self.agent_name.is_none()
&& self.harness.is_none()
&& self.model.is_none()
&& self.session.is_none()
}
}
pub struct MutationContext {
pub op_name: String,
pub actor: String,
/// Attribution stamped onto every event this context records (issue #312,
/// Layer 3 capture-only). Defaults to empty; set per-command before the
/// mutation runs. Capture-only — never used for gating.
pub attribution: EventAttribution,
pub events: Vec<Event>,
pub dirty_ids: HashSet<String>,
pub invalidate_blocked_cache: bool,
/// When set, only these issue IDs and their connected parent-child
/// components need their blocked-cache entries recomputed. If `None`
/// while `invalidate_blocked_cache` is true, the entire cache is rebuilt.
pub cache_affected_ids: Option<HashSet<String>>,
/// When true, skip the storage-layer post-commit cache refresh. Command
/// callers that already hold `.write.lock` can finalize the cache once per
/// batch; direct reads compute blocked state in-memory if the marker remains.
pub defer_cache_refresh: bool,
pub force_flush: bool,
pub capacity_warnings: Vec<crate::close_policy::WorkflowCapacityWarning>,
}
#[derive(Debug, Clone)]
enum BlockedCacheRefreshPlan {
Full,
Incremental(HashSet<String>),
/// The stale marker has been set inside the transaction; skip the
/// storage-layer post-commit rebuild. Higher-level command batching can
/// refresh once after the batch, while direct reads compute in-memory if the
/// marker remains.
Deferred,
}
impl BlockedCacheRefreshPlan {
fn from_context(ctx: &MutationContext) -> Option<Self> {
if !ctx.invalidate_blocked_cache {
return None;
}
if ctx.defer_cache_refresh {
return Some(Self::Deferred);
}
match &ctx.cache_affected_ids {
Some(ids) if !ids.is_empty() => Some(Self::Incremental(ids.clone())),
_ => Some(Self::Full),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ReadyIssueProjection {
Full,
Command,
Summary,
}
#[derive(Clone, Copy)]
enum SearchIssueProjection {
Full,
CommandText,
}
/// Shared case-insensitive needle match used by every `br search` query path.
///
/// Matches the issue's title, description, and id, plus the bodies of its
/// comments (beads_rust#416): agent workflows put durable handoffs and
/// decisions in comments, so a comment-only token must still be findable.
/// Binds four identical lowercase needle parameters.
const SEARCH_NEEDLE_PREDICATE: &str = "(instr(lower(title), ?) > 0 \
OR instr(lower(description), ?) > 0 \
OR instr(lower(id), ?) > 0 \
OR EXISTS (SELECT 1 FROM comments \
WHERE comments.issue_id = issues.id \
AND instr(lower(comments.text), ?) > 0))";
/// Equivalent search predicate for whole-corpus counts.
///
/// Unlike the result query, the hidden-closed count must inspect every eligible
/// closed issue. Materializing the matching comment issue IDs once avoids
/// rerunning the comment lookup for every outer issue while preserving the
/// exact substring and deduplication semantics of `SEARCH_NEEDLE_PREDICATE`.
const SEARCH_COUNT_NEEDLE_PREDICATE: &str = "(instr(lower(title), ?) > 0 \
OR instr(lower(description), ?) > 0 \
OR instr(lower(id), ?) > 0 \
OR issues.id IN (SELECT comments.issue_id FROM comments \
WHERE instr(lower(comments.text), ?) > 0))";
#[derive(Clone, Copy)]
enum BlockedIssueProjection {
Full,
Command,
}
struct ReadyReadinessProbe {
has_candidate_status: bool,
blocked_cache_stale: bool,
}
fn effective_ready_statuses(filters: &ReadyFilters) -> Vec<String> {
let mut statuses = if filters.ready_statuses.is_empty() {
vec!["open".to_string()]
} else {
filters.ready_statuses.clone()
};
if filters.include_deferred
&& !statuses
.iter()
.any(|status| status.eq_ignore_ascii_case("deferred"))
{
statuses.push("deferred".to_string());
}
statuses
}
fn ready_status_sql_literals(filters: &ReadyFilters) -> String {
effective_ready_statuses(filters)
.iter()
.map(|status| format!("'{}'", status.replace('\'', "''")))
.collect::<Vec<_>>()
.join(",")
}
struct IssueDetailRelationPresence {
has_labels: bool,
has_dependencies: bool,
has_dependents: bool,
has_comments: bool,
has_children: bool,
parent: Option<String>,
}
struct ImportIssueTimestampStrings {
created_at: String,
updated_at: String,
closed_at: Option<String>,
due_at: Option<String>,
defer_until: Option<String>,
deleted_at: Option<String>,
compacted_at: Option<String>,
}
impl ImportIssueTimestampStrings {
fn from_issue(issue: &Issue) -> Self {
Self {
created_at: issue.created_at.to_rfc3339(),
updated_at: issue.updated_at.to_rfc3339(),
closed_at: issue.closed_at.map(|dt| dt.to_rfc3339()),
due_at: issue.due_at.map(|dt| dt.to_rfc3339()),
defer_until: issue.defer_until.map(|dt| dt.to_rfc3339()),
deleted_at: issue.deleted_at.map(|dt| dt.to_rfc3339()),
compacted_at: issue.compacted_at.map(|dt| dt.to_rfc3339()),
}
}
}
impl ReadyIssueProjection {
const fn select_clause(self) -> &'static str {
match self {
Self::Full => {
r"SELECT 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"
}
Self::Command => {
r"SELECT id, title, description, acceptance_criteria, notes, status, priority,
issue_type, assignee, owner, estimated_minutes, created_at, created_by,
updated_at"
}
Self::Summary => {
r"SELECT id, title, status, priority, issue_type, created_at, updated_at"
}
}
}
fn parse_row(self, row: &Row) -> Result<Issue> {
match self {
Self::Full => SqliteStorage::issue_from_row(row),
Self::Command => SqliteStorage::ready_issue_from_row(row),
Self::Summary => SqliteStorage::command_summary_issue_from_row(row),
}
}
}
impl SearchIssueProjection {
const fn select_clause(self) -> &'static str {
match self {
Self::Full => {
r"SELECT 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
FROM issues
WHERE 1=1"
}
Self::CommandText => {
r"SELECT id, title, description, status, priority, issue_type, assignee,
created_at, updated_at
FROM issues
WHERE 1=1"
}
}
}
fn parse_issue(self, row: &Row) -> Result<Issue> {
match self {
Self::Full => SqliteStorage::issue_from_row(row),
Self::CommandText => SqliteStorage::search_command_issue_from_row(row),
}
}
}
impl BlockedIssueProjection {
const fn cached_select_clause(self) -> &'static str {
match self {
Self::Full => {
r"SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.owner, i.estimated_minutes,
i.created_at, i.created_by, i.updated_at, i.closed_at, i.close_reason, i.closed_by_session,
i.due_at, i.defer_until, i.external_ref, i.source_system, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, i.compaction_level,
i.compacted_at, i.compacted_at_commit, i.original_size, i.sender, i.ephemeral,
i.pinned, i.is_template, i.source_repo_path, i.agent_context,
bc.blocked_by"
}
Self::Command => {
r"SELECT i.id, i.title, i.description, i.status, i.priority, i.issue_type,
i.created_at, i.created_by, i.updated_at, bc.blocked_by"
}
}
}
const fn map_select_clause(self) -> &'static str {
match self {
Self::Full => {
r"SELECT 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"
}
Self::Command => {
r"SELECT id, title, description, status, priority, issue_type,
created_at, created_by, updated_at"
}
}
}
const fn cached_blocked_by_index(self) -> usize {
match self {
// Bumped from 37 → 38 after `agent_context` was appended
// to the Full SELECT at position 37 (beads_rust#297).
// Source_repo_path is at 36, agent_context is at 37, so
// bc.blocked_by lands at 38 in the joined projection.
Self::Full => 38,
Self::Command => 9,
}
}
fn parse_issue(self, row: &Row) -> Result<Issue> {
match self {
Self::Full => SqliteStorage::issue_from_row(row),
Self::Command => SqliteStorage::blocked_command_issue_from_row(row),
}
}
}
impl MutationContext {
#[must_use]
pub fn new(op_name: &str, actor: &str) -> Self {
Self {
op_name: op_name.to_string(),
actor: actor.to_string(),
attribution: EventAttribution::default(),
events: Vec::new(),
dirty_ids: HashSet::new(),
invalidate_blocked_cache: false,
cache_affected_ids: None,
defer_cache_refresh: false,
force_flush: false,
capacity_warnings: Vec::new(),
}
}
pub fn record_event(&mut self, event_type: EventType, issue_id: &str, details: Option<String>) {
self.events.push(Event {
id: 0, // Placeholder, DB assigns auto-inc ID
issue_id: issue_id.to_string(),
event_type,
actor: self.actor.clone(),
old_value: None,
new_value: None,
comment: details,
created_at: Utc::now(),
agent_name: self.attribution.agent_name.clone(),
harness: self.attribution.harness.clone(),
model: self.attribution.model.clone(),
});
}
/// Record a field change event with old and new values.
pub fn record_field_change(
&mut self,
event_type: EventType,
issue_id: &str,
old_value: Option<String>,
new_value: Option<String>,
comment: Option<String>,
) {
self.events.push(Event {
id: 0,
issue_id: issue_id.to_string(),
event_type,
actor: self.actor.clone(),
old_value,
new_value,
comment,
created_at: Utc::now(),
agent_name: self.attribution.agent_name.clone(),
harness: self.attribution.harness.clone(),
model: self.attribution.model.clone(),
});
}
pub fn mark_dirty(&mut self, issue_id: &str) {
self.dirty_ids.insert(issue_id.to_string());
}
pub fn invalidate_cache(&mut self) {
self.invalidate_blocked_cache = true;
// Force full rebuild by clearing any incremental affected set.
self.cache_affected_ids = None;
}
/// Signal that only specific issues need their blocked-cache entries
/// recomputed (incremental update). Merges with any previously recorded
/// affected IDs. If `invalidate_cache()` was already called (which sets
/// `cache_affected_ids = None`), the full rebuild path takes precedence.
pub fn invalidate_cache_for(&mut self, ids: &[&str]) {
if self.invalidate_blocked_cache && self.cache_affected_ids.is_none() {
return;
}
self.invalidate_blocked_cache = true;
let set = self.cache_affected_ids.get_or_insert_with(HashSet::new);
for id in ids {
set.insert((*id).to_string());
}
}
/// Mark the blocked-cache as needing invalidation but defer the storage-layer
/// rebuild. Higher-level command batching can refresh once after the batch;
/// reads compute in-memory if the marker remains.
///
/// Use this for high-frequency write operations (dep add/remove) where the
/// caller can reconcile the cache at a command boundary instead of inside
/// every individual storage mutation.
pub fn invalidate_cache_deferred(&mut self) {
self.invalidate_blocked_cache = true;
self.defer_cache_refresh = true;
}
}
pub(crate) struct ReconcileTransactionOutcome<T> {
pub value: T,
pub foreign_keys_restored: bool,
pub database_authority_preserved: bool,
}
impl SqliteStorage {
#[cfg(test)]
pub(crate) fn arm_database_replacement_after_commit_for_test() {
REPLACE_ATTACHED_DATABASE_AFTER_COMMIT.with(|replace| replace.set(true));
}
#[cfg(test)]
fn arm_user_version_change_after_runtime_compatibility_for_test(version: u32) {
CHANGE_USER_VERSION_AFTER_RUNTIME_COMPATIBILITY.with(|pending| pending.set(version));
}
#[cfg(test)]
fn maybe_change_user_version_after_runtime_compatibility(conn: &Connection) -> Result<()> {
let version =
CHANGE_USER_VERSION_AFTER_RUNTIME_COMPATIBILITY.with(|pending| pending.replace(0));
if version != 0 {
conn.execute(&format!("PRAGMA user_version = {version}"))?;
}
Ok(())
}
#[cfg(all(test, unix))]
fn arm_namespace_sidecar_swap_after_open_for_test(victim: PathBuf) {
SWAP_NAMESPACE_SIDECAR_AFTER_OPEN.with(|pending| {
*pending.borrow_mut() = Some(victim);
});
}
#[cfg(all(test, unix))]
fn set_namespace_sidecar_chmod_ignored_for_test(ignored: bool) {
IGNORE_NAMESPACE_SIDECAR_CHMOD.with(|flag| flag.set(ignored));
}
pub(crate) fn attach_write_authority(
&mut self,
authority: Arc<crate::sync::DatabaseFamilyWriteLock>,
) {
self.write_authority = Some(authority);
}
pub(crate) fn attached_write_authority(
&self,
) -> Option<Arc<crate::sync::DatabaseFamilyWriteLock>> {
self.write_authority.clone()
}
fn verify_attached_database_authority(&self) -> Result<()> {
if let Some(authority) = self.write_authority.as_ref() {
authority.verify_database_authority()?;
}
Ok(())
}
fn verify_attached_database_authority_after_commit(
&self,
transaction_kind: &str,
) -> Result<()> {
self.verify_attached_database_authority().map_err(|source| {
BeadsError::CommittedStateUnwitnessed {
operation: transaction_kind.to_string(),
source: Box::new(source),
}
})
}
#[cfg(test)]
fn maybe_replace_attached_database_after_commit(&self) -> Result<()> {
let replace = REPLACE_ATTACHED_DATABASE_AFTER_COMMIT.with(|replace| replace.replace(false));
if !replace {
return Ok(());
}
let authority = self.write_authority.as_ref().ok_or_else(|| {
BeadsError::Config(
"post-commit database replacement test hook requires attached authority".into(),
)
})?;
let database_path = authority.canonical_database_path();
let mut displaced_name = database_path.as_os_str().to_os_string();
displaced_name.push(".postcommit-original");
let displaced_path = PathBuf::from(displaced_name);
std::fs::rename(database_path, &displaced_path)?;
std::fs::copy(&displaced_path, database_path)?;
Ok(())
}
fn with_connection_write_transaction<F, R>(&self, mut f: F) -> Result<R>
where
F: FnMut(&Connection) -> Result<R>,
{
// Issue #219: same retry parameters as with_write_transaction (see
// that method for rationale). This shared-connection variant is used
// for blocked-cache rebuilds and metadata writes which also contend
// for the write lock under parallel agent operations.
const MAX_RETRIES: u32 = 8;
let base_backoff_ms: u64 = 50;
let mut last_error: Option<crate::error::BeadsError> = None;
for attempt in 0..MAX_RETRIES {
self.verify_attached_database_authority()?;
match self.conn.execute("BEGIN IMMEDIATE") {
Ok(_) => {}
Err(e) if e.is_transient() && attempt < MAX_RETRIES - 1 => {
last_error = Some(e.into());
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
continue;
}
Err(e) => return Err(e.into()),
}
match f(&self.conn) {
Ok(result) => {
if let Some(authority) = self.write_authority.as_ref()
&& let Err(authority_error) = authority.verify_database_authority()
{
return Err(Self::rollback_transaction_error(
&self.conn,
authority_error,
"database authority changed before shared COMMIT",
));
}
match self.conn.execute("COMMIT") {
Ok(_) => {
#[cfg(test)]
self.maybe_replace_attached_database_after_commit()?;
self.verify_attached_database_authority_after_commit(
"shared write transaction",
)?;
return Ok(result);
}
Err(e) if e.is_transient() && attempt < MAX_RETRIES - 1 => {
let commit_error = e.into();
if let Err(rollback_error) = Self::rollback_transaction(
&self.conn,
"transient shared COMMIT error",
) {
return Err(BeadsError::WithContext {
context: rollback_error,
source: Box::new(commit_error),
});
}
last_error = Some(commit_error);
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
}
Err(e) => {
return Err(Self::rollback_transaction_error(
&self.conn,
e.into(),
"shared COMMIT error",
));
}
}
}
Err(e) => {
if let Err(rollback_error) =
Self::rollback_transaction(&self.conn, "shared transaction body error")
{
return Err(BeadsError::WithContext {
context: rollback_error,
source: Box::new(e),
});
}
if e.is_transient() && attempt < MAX_RETRIES - 1 {
last_error = Some(e);
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
} else {
return Err(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| {
crate::error::BeadsError::Config(
"connection write transaction retry loop exhausted without producing an error"
.into(),
)
}))
}
fn metadata_key_exists(conn: &Connection, key: &str) -> Result<bool> {
let rows = conn.query_with_params(
"SELECT 1 FROM metadata WHERE key = ? LIMIT 1",
&[SqliteValue::from(key)],
)?;
Ok(!rows.is_empty())
}
fn upsert_metadata_key_in_tx(conn: &Connection, key: &str, value: &str) -> Result<()> {
let updated = conn.execute_with_params(
"UPDATE metadata SET value = ? WHERE key = ? AND value != ?",
&[
SqliteValue::from(value),
SqliteValue::from(key),
SqliteValue::from(value),
],
)?;
if updated == 0 && !Self::metadata_key_exists(conn, key)? {
conn.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[SqliteValue::from(key), SqliteValue::from(value)],
)?;
}
Ok(())
}
fn insert_metadata_default_if_missing(
conn: &Connection,
key: &str,
default_value: &str,
) -> Result<()> {
conn.execute_with_params(
"INSERT INTO metadata (key, value)
SELECT ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM metadata WHERE key = ? LIMIT 1
)",
&[
SqliteValue::from(key),
SqliteValue::from(default_value),
SqliteValue::from(key),
],
)?;
Ok(())
}
fn ensure_known_metadata_defaults(conn: &Connection) -> Result<()> {
// Read-first, write-only-if-missing pattern (#243). The read
// (`metadata_key_exists`) needs no write lock, so ordinary opens do
// not contend with active writers. If a key is missing, the INSERT
// re-checks existence inside the statement because `metadata.key` is
// intentionally not unique; `INSERT OR IGNORE` would not protect
// against duplicate default rows.
for (key, default_value) in KNOWN_METADATA_DEFAULTS {
if Self::metadata_key_exists(conn, key)? {
continue;
}
match Self::insert_metadata_default_if_missing(conn, key, default_value) {
Ok(()) => {}
Err(e) if e.is_transient() => {
// BUSY — another writer is active. The default will be
// present once their transaction commits, or we'll seed
// it on the next open.
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn metadata_equals(conn: &Connection, key: &str, expected: &str) -> Result<bool> {
match conn.query_row_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid DESC LIMIT 1",
&[SqliteValue::from(key)],
) {
Ok(row) => Ok(row.get(0).and_then(SqliteValue::as_text) == Some(expected)),
Err(fsqlite_error::FrankenError::QueryReturnedNoRows) => Ok(false),
Err(error) => Err(error.into()),
}
}
fn ready_readiness_probe(&self, filters: &ReadyFilters) -> Result<ReadyReadinessProbe> {
let status_literals = ready_status_sql_literals(filters);
let sql = format!(
"SELECT
EXISTS(SELECT 1 FROM issues WHERE status IN ({status_literals}) LIMIT 1),
COALESCE((SELECT value = ? FROM metadata WHERE key = ? ORDER BY rowid DESC LIMIT 1), 0)"
);
let row = self.conn.query_row_with_params(
&sql,
&[
SqliteValue::from(BLOCKED_CACHE_STATE_STALE),
SqliteValue::from(BLOCKED_CACHE_STATE_KEY),
],
)?;
Ok(ReadyReadinessProbe {
has_candidate_status: row
.get(0)
.and_then(SqliteValue::as_integer)
.is_some_and(|value| value != 0),
blocked_cache_stale: row
.get(1)
.and_then(SqliteValue::as_integer)
.is_some_and(|value| value != 0),
})
}
fn apply_blocked_cache_refresh_plan(
conn: &Connection,
plan: &BlockedCacheRefreshPlan,
) -> Result<usize> {
match plan {
BlockedCacheRefreshPlan::Full => Self::rebuild_blocked_cache_impl(conn),
BlockedCacheRefreshPlan::Incremental(ids) => {
Self::incremental_blocked_cache_update(conn, ids)
}
// Deferred plan is never applied eagerly; the stale marker already
// set inside the write transaction signals reads to compute in-memory.
BlockedCacheRefreshPlan::Deferred => Ok(0),
}
}
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 Self::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, Self::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),
}),
}
}
/// Run a recovery transaction with FK enforcement suppressed only for the
/// duration required by fsqlite's cache-rebuild workaround (#215).
///
/// The caller remains responsible for an explicit in-transaction
/// `foreign_key_check` before commit. FK enforcement is restored and
/// verified after both commit and rollback.
pub(crate) fn with_reconcile_transaction<F, T>(
&mut self,
operation: &str,
mut f: F,
) -> Result<ReconcileTransactionOutcome<T>>
where
F: FnMut(&mut Self) -> Result<T>,
{
self.conn.execute("PRAGMA foreign_keys = OFF")?;
let mut completed_value = None;
let result = self.with_write_transaction(|storage| {
completed_value = Some(f(storage)?);
Ok(())
});
match result {
Ok(()) => {
let foreign_keys_restored = match Self::restore_foreign_keys(&self.conn, operation)
{
Ok(()) => true,
Err(error) => {
tracing::error!(
operation,
error = %error,
"Transaction committed, but foreign-key enforcement could not be restored on the disposable recovery connection"
);
false
}
};
Ok(ReconcileTransactionOutcome {
value: completed_value.ok_or_else(|| BeadsError::Internal {
message: format!(
"{operation} committed without retaining its transaction result"
),
})?,
foreign_keys_restored,
database_authority_preserved: true,
})
}
Err(committed_error @ BeadsError::CommittedStateUnwitnessed { .. })
if completed_value.is_some() =>
{
let value = completed_value.ok_or_else(|| BeadsError::Internal {
message: format!(
"{operation} committed with unwitnessed authority but no transaction result was retained"
),
})?;
let foreign_keys_restored = match Self::restore_foreign_keys(&self.conn, operation)
{
Ok(()) => true,
Err(error) => {
tracing::error!(
operation,
error = %error,
"Transaction committed with unwitnessed database authority, and foreign-key enforcement could not be restored"
);
false
}
};
tracing::error!(
operation,
error = %committed_error,
"Transaction committed, but database authority changed; automatic retry is forbidden"
);
Ok(ReconcileTransactionOutcome {
value,
foreign_keys_restored,
database_authority_preserved: false,
})
}
Err(original_error) => match Self::restore_foreign_keys(&self.conn, operation) {
Ok(()) => Err(original_error),
Err(restore_error) => Err(BeadsError::WithContext {
context: format!(
"{operation} rolled back, and SQLite foreign key enforcement could not be re-enabled: {restore_error}"
),
source: Box::new(original_error),
}),
},
}
}
fn refresh_blocked_cache_after_commit(
&self,
op: &str,
plan: &BlockedCacheRefreshPlan,
) -> Result<()> {
// Disable FK enforcement before the transaction. PRAGMA foreign_keys
// can only be changed outside an active transaction. fsqlite can
// surface false FK violations on blocked_issues_cache inserts (#215).
self.conn.execute("PRAGMA foreign_keys = OFF")?;
let result = self.with_connection_write_transaction(|conn| {
let refreshed = Self::apply_blocked_cache_refresh_plan(conn, plan)?;
Self::upsert_metadata_key_in_tx(conn, BLOCKED_CACHE_STATE_KEY, METADATA_EMPTY_VALUE)?;
tracing::debug!(operation = op, refreshed, "Refreshed blocked issues cache");
Ok(())
});
Self::finish_foreign_key_suppressed_result(&self.conn, "blocked-cache refresh", result)
}
fn handle_blocked_cache_refresh_error(&self, op: &str, error: BeadsError) -> Result<()> {
match Self::foreign_keys_enabled(&self.conn) {
Ok(true) => {
tracing::warn!(
operation = op,
error = %error,
"Blocked cache refresh deferred after commit; cache remains marked stale"
);
Ok(())
}
Ok(false) => Err(BeadsError::WithContext {
context: format!(
"post-commit blocked-cache refresh for {op} failed and SQLite foreign key enforcement is OFF"
),
source: Box::new(error),
}),
Err(check_error) => Err(BeadsError::WithContext {
context: format!(
"post-commit blocked-cache refresh for {op} failed and SQLite foreign key enforcement status could not be verified: {check_error}"
),
source: Box::new(error),
}),
}
}
pub(crate) fn blocked_cache_marked_stale(&self) -> Result<bool> {
Self::metadata_equals(
&self.conn,
BLOCKED_CACHE_STATE_KEY,
BLOCKED_CACHE_STATE_STALE,
)
}
/// Mark the blocked-cache as stale so a future read can rebuild it on demand.
///
/// # Errors
///
/// Returns an error if the metadata update fails.
pub(crate) fn mark_blocked_cache_stale(&mut self) -> Result<()> {
self.set_metadata(BLOCKED_CACHE_STATE_KEY, BLOCKED_CACHE_STATE_STALE)
}
pub(crate) fn ensure_blocked_cache_fresh(&self) -> Result<bool> {
if !self.blocked_cache_marked_stale()? {
return Ok(false);
}
// Disable FK enforcement before the transaction. PRAGMA foreign_keys
// can only be changed outside an active transaction. fsqlite can
// surface false FK violations on blocked_issues_cache inserts (#215).
self.conn.execute("PRAGMA foreign_keys = OFF")?;
let result = self.with_connection_write_transaction(|conn| {
if !Self::metadata_equals(conn, BLOCKED_CACHE_STATE_KEY, BLOCKED_CACHE_STATE_STALE)? {
return Ok(false);
}
let refreshed = Self::rebuild_blocked_cache_impl(conn)?;
Self::upsert_metadata_key_in_tx(conn, BLOCKED_CACHE_STATE_KEY, METADATA_EMPTY_VALUE)?;
tracing::debug!(refreshed, "Rebuilt stale blocked issues cache on demand");
Ok(true)
});
Self::finish_foreign_key_suppressed_result(&self.conn, "blocked-cache lazy rebuild", result)
}
/// Open a new connection to the database at the given path.
///
/// # Errors
///
/// Returns an error if the connection cannot be established or schema application fails.
pub fn open(path: &Path) -> Result<Self> {
Self::open_with_timeout(path, Some(DEFAULT_BUSY_TIMEOUT_MS))
}
/// Open a new connection with an optional busy timeout (ms).
///
/// # Errors
///
/// Returns an error if the connection cannot be established or schema application fails.
pub fn open_with_timeout(path: &Path, lock_timeout_ms: Option<u64>) -> Result<Self> {
// This generic opener may be used by read-only or library callers that
// do not hold the database-family authority. It must never repair
// namespace sidecars: even a chmod is a database-family mutation, and
// a raw header alone cannot rule out a future user_version in WAL.
// Authority-aware startup/recovery callers use
// `open_with_timeout_under_write_authority` below.
preflight_effective_schema_before_writable_open(path)?;
// Register as an opener before the engine open so this process never
// starts reading a WAL that a peer's exclusive checkpoint is resetting.
let opener_lease = Some(crate::sync::DatabaseOpenerLease::register(path)?);
let absent_namespace_sidecars = absent_namespace_sidecar_suffixes(path);
let conn = Connection::open(path.to_string_lossy().into_owned())
.map_err(|error| explain_engine_open_error(path, &absent_namespace_sidecars, error))?;
// Keep SQLite's busy handler aligned with the caller's requested
// bound. The `.write.lock` serializes normal mutating processes, while
// `with_write_transaction` provides the bounded database-level retry.
if let Some(timeout_ms) = lock_timeout_ms {
conn.execute(&format!("PRAGMA busy_timeout={timeout_ms}"))?;
}
// Ordinary opens keep the shipped auto-migration contract: a database
// behind CURRENT_SCHEMA_VERSION is migrated in place (legacy fleets
// depend on this — pre-v13 databases have no reviewed migration
// pair). The reviewed `br doctor migrate-schema` lifecycle remains
// the explicit, receipt-bound alternative for operator-driven
// migrations of supported version pairs.
let current_schema_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap_or(0);
let header_schema_version = checked_database_header_user_version(path)?;
let effective_schema_version = connection_user_version(&conn).or(header_schema_version);
if let Some(version) = effective_schema_version
&& version > current_schema_version
{
return Err(BeadsError::Config(format!(
"Database schema version {version} is newer than this br binary supports \
({current_schema_version}); refusing to modify or downgrade it"
)));
}
let schema_current = effective_schema_version == Some(current_schema_version);
let schema_cookie_before = crate::storage::schema::runtime_schema_cookie(&conn)?;
// Steady state: the recorded witness names this exact SQLite schema
// cookie as already attested against the complete runtime contract,
// and any DDL since would have changed the cookie. Trusting it here
// (as the read-only fast open already does) skips the 11-table PRAGMA
// walk that cost ~24 ms per open, twice per mutating command.
let witness_matches = schema_current && runtime_schema_witness_matches(&conn);
let runtime_compatible = witness_matches || runtime_schema_compatible(&conn);
#[cfg(test)]
Self::maybe_change_user_version_after_runtime_compatibility(&conn)?;
let schema_cookie_after = crate::storage::schema::runtime_schema_cookie(&conn)?;
let attested_cookie = if schema_current && runtime_compatible {
crate::storage::schema::apply_runtime_pragmas(&conn)?;
let final_schema_cookie = crate::storage::schema::runtime_schema_cookie(&conn)?;
let final_user_version = connection_user_version(&conn);
if schema_cookie_before == schema_cookie_after
&& schema_cookie_after == final_schema_cookie
&& final_user_version == Some(current_schema_version)
{
final_schema_cookie
} else {
attest_runtime_schema_cookie(&conn)?
}
} else if runtime_compatible {
apply_runtime_compatible_schema(&conn)?;
attest_runtime_schema_cookie(&conn)?
} else {
apply_schema(&conn)?;
attest_runtime_schema_cookie(&conn)?
};
Self::ensure_known_metadata_defaults(&conn)?;
// A matching witness is already on disk for this cookie; re-writing
// it would only add a write transaction to every steady-state open.
if !(witness_matches && attested_cookie == schema_cookie_before)
&& let Err(error) = record_runtime_schema_witness(&conn, attested_cookie)
{
tracing::debug!(
%error,
"runtime schema witness could not be recorded; future fast opens will revalidate"
);
}
Ok(Self {
conn,
write_authority: None,
mutation_count: 0,
temp_db_path: None,
pending_event_attribution: None,
opener_lease,
workflow_capacity_policy: crate::close_policy::CapacityPolicy::default(),
workflow_transition_policy: crate::close_policy::Workflow::default(),
last_capacity_warnings: Vec::new(),
})
}
/// Open while holding the exact database-family authority, repairing
/// over-permissive fsqlite namespace sidecars before the engine open.
///
/// The repair is fail-closed: the authority must protect this exact path,
/// the live database inode must already be bound, and the effective schema
/// version must be provably non-future before any permission bit changes.
pub(crate) fn open_with_timeout_under_write_authority(
path: &Path,
lock_timeout_ms: Option<u64>,
authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<Self> {
heal_namespace_sidecar_modes_under_authority(path, authority)?;
authority.verify_database_authority()?;
let mut storage = Self::open_with_timeout(path, lock_timeout_ms)?;
authority.verify_database_authority()?;
storage.attach_write_authority(Arc::clone(authority));
Ok(storage)
}
/// Strip group/other permission bits from fsqlite's namespace sidecars
/// under the exact database-family authority, without opening the
/// database. See [`open_with_timeout_under_write_authority`] for the
/// fail-closed preconditions; a filesystem that cannot hold the bits
/// fails with the named limitation (GitHub #491).
///
/// [`open_with_timeout_under_write_authority`]: Self::open_with_timeout_under_write_authority
pub(crate) fn repair_namespace_sidecar_modes_under_authority(
path: &Path,
authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<()> {
heal_namespace_sidecar_modes_under_authority(path, authority)
}
/// Whether the lock-free read-only lane would decline `path` because an
/// fsqlite namespace sidecar beside it is group/other accessible and
/// needs the authority-gated owner-only repair first (GitHub #403).
pub(crate) fn namespace_sidecars_need_mode_repair(path: &Path) -> Result<bool> {
namespace_sidecar_mode_repair_required(path)
}
/// Whether the engine linked into this binary admits a namespace sidecar
/// with `sidecar_mode`/`sidecar_gid` beside `db_path` exactly as it is:
/// owner-only always, and (FrankenSQLite 0.3.18+) a group/other exposure
/// bounded by the database file's (GitHub #491).
#[cfg(unix)]
pub(crate) fn namespace_sidecar_mode_is_admitted(
sidecar_mode: u32,
sidecar_gid: u32,
db_path: &Path,
) -> bool {
sidecar_mode.trailing_zeros() >= 6
|| (sidecar_exposure_is_database_bounded(sidecar_mode, sidecar_gid, db_path)
&& engine_accepts_database_bounded_sidecar_exposure())
}
/// The linked engine's sidecar permission rule, for doctor findings.
#[cfg(unix)]
pub(crate) fn engine_namespace_sidecar_rule() -> String {
engine_sidecar_rule_text()
}
pub(crate) fn open_current_read_only(path: &Path) -> Result<Option<Self>> {
let current_schema_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap_or(0);
// Cheap header pre-filter: only reject when the file is definitively not
// a SQLite database (no valid header magic). A low header *version* must
// NOT disqualify — an uncheckpointed WAL from another process may hold
// the true, current value that the raw header bytes do not yet reflect
// (issue #373). A real database always carries the header magic even
// when its user_version lives only in the WAL.
if checked_database_header_user_version(path)?.is_none() {
return Ok(None);
}
// A lock-free read-only fast open must be observational only. If a
// namespace sidecar needs repair, decline this path so startup can
// acquire the database-family authority and perform the repair there.
if namespace_sidecar_mode_repair_required(path)? {
return Ok(None);
}
let opener_lease = Some(crate::sync::DatabaseOpenerLease::register(path)?);
let conn = open_with_flags(
path.to_string_lossy().as_ref(),
OpenFlags::SQLITE_OPEN_READ_ONLY,
)?;
// Now that the connection is open, consult the effective schema version
// (WAL-aware) and fall back to the header peek. Reviewed reconciliation
// is intentionally exact-version only: a future schema may add columns,
// triggers, or invariants that this binary cannot witness safely.
let header_version = checked_database_header_user_version(path)?;
if connection_user_version(&conn).or(header_version) != Some(current_schema_version) {
conn.close().map_err(BeadsError::Database)?;
return Ok(None);
}
Ok(Some(Self {
conn,
write_authority: None,
mutation_count: 0,
temp_db_path: None,
pending_event_attribution: None,
opener_lease,
workflow_capacity_policy: crate::close_policy::CapacityPolicy::default(),
workflow_transition_policy: crate::close_policy::Workflow::default(),
last_capacity_warnings: Vec::new(),
}))
}
pub(crate) fn fast_open_runtime_schema_is_compatible(&self) -> bool {
// A current version stamp is not a complete runtime witness: reviewed
// migrations and interrupted/manual repairs can leave any required
// table, column, or index absent without changing that stamp. Ordinary
// open records SQLite's schema cookie only after the complete runtime
// contract passes. A changed cookie forces the authoritative full
// check; databases created before this witness also take that safe
// fallback until an ordinary open records one.
runtime_schema_witness_matches(&self.conn)
|| attest_runtime_schema_cookie(&self.conn).is_ok()
}
/// Open an existing current-schema database for a token-bound recovery write.
///
/// Unlike [`Self::open`], this never creates, migrates, repairs, or seeds
/// metadata. Callers must hold the project writer lock before opening it.
pub(crate) fn open_current_for_reconcile(
path: &Path,
lock_timeout_ms: Option<u64>,
) -> Result<Option<Self>> {
let current_schema_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap_or(0);
let Some(header_version) = checked_database_header_user_version(path)? else {
return Ok(None);
};
if header_version > current_schema_version {
return Ok(None);
}
// A current main header is not sufficient authority for a read-write
// engine open: the effective user_version may live only in a committed
// WAL page-one frame. Inspect it byte-neutrally first so refusing an
// unknown future version cannot create or rewrite namespace sidecars.
let wal_preflight = sqlite_wal_schema_preflight(path)?;
let effective_version = wal_preflight
.committed_user_version
.unwrap_or(header_version);
if effective_version != current_schema_version {
return Ok(None);
}
// This legacy helper is intentionally nonmutating. Its sole production
// caller already holds authority, but the authority is not part of this
// API and therefore cannot justify a chmod here.
if namespace_sidecar_mode_repair_required(path)? {
return Err(BeadsError::SyncConflict {
message: "Token-bound reconciliation requires authority-gated fsqlite namespace sidecar repair before opening the database".to_string(),
});
}
let opener_lease = Some(crate::sync::DatabaseOpenerLease::register(path)?);
let absent_namespace_sidecars = absent_namespace_sidecar_suffixes(path);
let conn = open_with_flags(
path.to_string_lossy().as_ref(),
OpenFlags::SQLITE_OPEN_READ_WRITE,
)
.map_err(|error| explain_engine_open_error(path, &absent_namespace_sidecars, error))?;
if let Some(timeout_ms) = lock_timeout_ms {
conn.execute(&format!("PRAGMA busy_timeout={timeout_ms}"))?;
}
let header_version = checked_database_header_user_version(path)?;
if connection_user_version(&conn).or(header_version) != Some(current_schema_version)
|| !runtime_schema_compatible(&conn)
{
return Ok(None);
}
Ok(Some(Self {
conn,
write_authority: None,
mutation_count: 0,
temp_db_path: None,
pending_event_attribution: None,
opener_lease,
workflow_capacity_policy: crate::close_policy::CapacityPolicy::default(),
workflow_transition_policy: crate::close_policy::Workflow::default(),
last_capacity_warnings: Vec::new(),
}))
}
/// Open an ephemeral, single-process scratch database.
///
/// FrankenSQLite requires real file paths for WAL and schema operations, so
/// this cannot use `:memory:`; instead it opens a uniquely-named temp file
/// (`beads_mem_<pid>_<count>.db`) under [`std::env::temp_dir`]. The file —
/// and any `-wal`/`-shm`/`-journal` sidecars SQLite creates alongside it —
/// is unlinked when the returned [`SqliteStorage`] is dropped (#299), and
/// also if construction fails partway through, so no stale files are left in
/// `TMPDIR`. The path is unique per process, so it is never shared with
/// another process and is always safe to delete on teardown.
///
/// # Errors
///
/// Returns an error if the connection cannot be established.
pub fn open_memory() -> Result<Self> {
static MEM_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
const OPEN_ATTEMPTS: usize = 8;
const BACKOFF_CAP: Duration = Duration::from_millis(100);
let mut backoff = Duration::from_millis(2);
for attempt in 0..OPEN_ATTEMPTS {
let count = MEM_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("beads_mem_{}_{}.db", std::process::id(), count));
// Build the storage on a fallible path so that any failure after
// the file has been created still removes it (the partial
// `Connection` / file would otherwise be orphaned without a live
// `Drop` to clean it).
match Self::build_memory(&path) {
Ok(storage) => return Ok(storage),
Err(error) => {
remove_temp_db_files(&path);
let retryable = matches!(
&error,
BeadsError::Database(FrankenError::CannotOpen { .. })
);
if !retryable || attempt + 1 == OPEN_ATTEMPTS {
return Err(error);
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_CAP);
}
}
}
unreachable!("the bounded scratch-database open loop always returns")
}
fn build_memory(path: &Path) -> Result<Self> {
let conn = Connection::open(path.to_string_lossy().into_owned())?;
conn.execute(&format!("PRAGMA busy_timeout={DEFAULT_BUSY_TIMEOUT_MS}"))?;
if let Err(e) = apply_schema(&conn) {
eprintln!("apply_schema failed: {:?}", e);
return Err(e);
}
Self::ensure_known_metadata_defaults(&conn)?;
Ok(Self {
conn,
write_authority: None,
mutation_count: 0,
temp_db_path: Some(path.to_path_buf()),
pending_event_attribution: None,
opener_lease: None,
workflow_capacity_policy: crate::close_policy::CapacityPolicy::default(),
workflow_transition_policy: crate::close_policy::Workflow::default(),
last_capacity_warnings: Vec::new(),
})
}
/// Drop and recreate all data tables, preserving `config` and `metadata`.
///
/// Used before force imports to avoid fsqlite btree cursor bugs on DELETE
/// operations in large tables. By starting with empty tables, the import
/// only performs INSERTs.
///
/// # Errors
///
/// Returns an error if any DROP/CREATE statement fails.
pub fn reset_data_tables(&mut self) -> Result<()> {
self.with_write_transaction(|storage| storage.reset_data_tables_in_tx())
}
fn reset_data_tables_in_tx(&self) -> Result<()> {
use crate::storage::schema::execute_batch;
execute_batch(
&self.conn,
r"
DROP TABLE IF EXISTS blocked_issues_cache;
DROP TABLE IF EXISTS export_hashes;
DROP TABLE IF EXISTS dirty_issues;
DROP TABLE IF EXISTS child_counters;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS labels;
DROP TABLE IF EXISTS dependencies;
DROP TABLE IF EXISTS gate_result_history;
DROP TABLE IF EXISTS gate_results;
DROP TABLE IF EXISTS capacity_exemption_history;
DROP TABLE IF EXISTS capacity_exemptions;
DROP TABLE IF EXISTS capacity_occupancy;
DROP TABLE IF EXISTS close_metadata;
DROP TABLE IF EXISTS issues;
",
)?;
// Recreate with full schema (config/metadata already exist, IF NOT EXISTS is safe).
// Use apply_runtime_compatible_schema rather than apply_schema because we are
// mid-session: the connection is already open with correct pragmas and we only
// need to restore the DDL without re-running heavier first-open migrations.
apply_runtime_compatible_schema(&self.conn)?;
let attested_cookie = attest_runtime_schema_cookie(&self.conn)?;
if let Err(error) = record_runtime_schema_witness(&self.conn, attested_cookie) {
tracing::debug!(
%error,
"reset schema witness could not be recorded; future fast opens will revalidate"
);
}
Ok(())
}
/// Detect recoverable on-disk anomalies that should trigger JSONL rebuild.
///
/// These checks run after the database opens successfully because some
/// malformed states remain queryable enough to reach startup, then fail on
/// the next single-row lookup.
///
/// Detect structured anomalies suitable for the canonical health classifier.
///
/// # Errors
///
/// Returns an error if probing the database fails.
pub fn detect_anomalies(&self) -> Result<Vec<crate::health::AnomalyClass>> {
use crate::health::AnomalyClass;
let mut anomalies = Vec::new();
let duplicate_schema_rows = self.conn.query(
"SELECT type, name, COUNT(*) AS row_count
FROM sqlite_master
WHERE name IN ('blocked_issues_cache', 'idx_blocked_cache_blocked_at')
GROUP BY type, name
HAVING COUNT(*) > 1
ORDER BY row_count DESC, name ASC",
)?;
for row in &duplicate_schema_rows {
let name = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("unknown")
.to_string();
let count = row.get(2).and_then(SqliteValue::as_integer).unwrap_or(2);
anomalies.push(AnomalyClass::DuplicateSchemaRows { name, count });
}
if let Some((key, count)) = self.first_duplicate_kv_key("config")? {
anomalies.push(AnomalyClass::DuplicateConfigKeys { key, count });
}
if let Some((key, count)) = self.first_duplicate_kv_key("metadata")? {
anomalies.push(AnomalyClass::DuplicateMetadataKeys { key, count });
}
Ok(anomalies)
}
/// # Errors
///
/// Returns an error if probing the database fails.
pub(crate) fn detect_recoverable_open_anomaly(&self) -> Result<Option<String>> {
let duplicate_schema_rows = self.conn.query(
"SELECT type, name, COUNT(*) AS row_count
FROM sqlite_master
WHERE name IN ('blocked_issues_cache', 'idx_blocked_cache_blocked_at')
GROUP BY type, name
HAVING COUNT(*) > 1
ORDER BY row_count DESC, name ASC
LIMIT 1",
)?;
if let Some(row) = duplicate_schema_rows.first() {
let object_type = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("object");
let name = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("unknown");
let row_count = row.get(2).and_then(SqliteValue::as_integer).unwrap_or(2);
return Ok(Some(format!(
"sqlite_master contains duplicate {object_type} entries for '{name}' ({row_count} rows)"
)));
}
if let Some((key, row_count)) = self.first_duplicate_kv_key("config")? {
return Ok(Some(format!(
"config contains duplicate rows for key '{key}' ({row_count} rows)"
)));
}
if let Some((key, row_count)) = self.first_duplicate_kv_key("metadata")? {
return Ok(Some(format!(
"metadata contains duplicate rows for key '{key}' ({row_count} rows)"
)));
}
Ok(None)
}
fn first_duplicate_kv_key(&self, table: &str) -> Result<Option<(String, i64)>> {
let sql = format!(
"SELECT key, COUNT(*) AS row_count
FROM {table}
GROUP BY key
HAVING COUNT(*) > 1
ORDER BY row_count DESC, key ASC
LIMIT 1"
);
let rows = self.conn.query(&sql)?;
let Some(row) = rows.first() else {
return Ok(None);
};
let key = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let row_count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(2);
Ok(Some((key, row_count)))
}
/// Execute a raw SQL statement (no parameters, no result).
///
/// Useful for PRAGMAs and DDL that don't fit the normal mutation flow.
///
/// # Errors
///
/// Returns an error if the statement fails.
pub(crate) fn execute_raw(&self, sql: &str) -> Result<()> {
self.verify_attached_database_authority()?;
self.conn.execute(sql)?;
self.verify_attached_database_authority()?;
Ok(())
}
/// Execute a raw SQL query and return all result rows.
///
/// # Errors
///
/// Returns an error if the query fails.
pub(crate) fn execute_raw_query(&self, sql: &str) -> Result<Vec<Vec<SqliteValue>>> {
let rows = self.conn.query(sql)?;
Ok(rows.iter().map(|r| r.values().to_vec()).collect())
}
/// Snapshot every DB-only auxiliary/history table so a JSONL rebuild can
/// carry them across (GitHub #471). The JSONL export holds issue state
/// only; without this, `br doctor --repair` silently empties the entire
/// provenance layer (events, gate results, close/bypass audit, capacity
/// records) while reporting success.
///
/// Fault-tolerant by design: the source DB is typically corrupt when this
/// runs, so each table is captured independently and a failed read is
/// reported in the returned `failures` list instead of aborting — a
/// partially preserved history beats none.
pub(crate) fn snapshot_auxiliary_history_tables(&self) -> AuxiliaryHistorySnapshot {
let mut snapshot = AuxiliaryHistorySnapshot::default();
for &table in AUXILIARY_HISTORY_TABLES {
if !crate::storage::schema::table_exists(&self.conn, table) {
continue;
}
let capture = (|| -> Result<PreservedTableRows> {
let mut columns = Vec::new();
for row in self.conn.query(&format!("PRAGMA table_info({table})"))? {
if let Some(name) = row.get(1).and_then(SqliteValue::as_text) {
columns.push(name.to_string());
}
}
if columns.is_empty() {
return Err(BeadsError::Config(format!(
"PRAGMA table_info({table}) returned no columns"
)));
}
let order_by = if columns.iter().any(|c| c == "id") {
" ORDER BY id ASC"
} else {
""
};
let rows = self
.conn
.query(&format!(
"SELECT {} FROM {table}{order_by}",
columns.join(", ")
))?
.iter()
.map(|row| row.values().to_vec())
.collect();
Ok(PreservedTableRows {
table: table.to_string(),
columns,
rows,
})
})();
match capture {
Ok(preserved) => {
if !preserved.rows.is_empty() {
snapshot.tables.push(preserved);
}
}
Err(err) => snapshot
.failures
.push(format!("{table}: could not snapshot rows ({err})")),
}
}
snapshot
}
/// Restore auxiliary/history rows captured by
/// [`Self::snapshot_auxiliary_history_tables`] into a freshly rebuilt
/// database (GitHub #471). Returns per-table `(restored, skipped)` counts.
///
/// * Columns are intersected with the rebuilt schema so a snapshot taken
/// from an older schema still restores its shared columns.
/// * Rows whose `issue_id` no longer exists after the rebuild are skipped
/// (the rebuild is authoritative for issue membership and the tables all
/// have `ON DELETE CASCADE` foreign keys to `issues`).
/// * For append-only tables with an autoincrement `id`, the original ids
/// are kept when the rebuilt table is empty; if the rebuild already
/// wrote rows (e.g. import events), the snapshot ids are dropped and the
/// rows appended in original order so nothing collides or is lost.
///
/// # Errors
///
/// Returns an error if a restore write fails.
pub(crate) fn restore_auxiliary_history_tables(
&self,
snapshot: &AuxiliaryHistorySnapshot,
) -> Result<Vec<(String, usize, usize)>> {
let mut report = Vec::new();
if snapshot.tables.is_empty() {
return Ok(report);
}
let live_issue_ids: std::collections::HashSet<String> = self
.conn
.query("SELECT id FROM issues")?
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
self.with_connection_write_transaction(|conn| {
for preserved in &snapshot.tables {
let table = preserved.table.as_str();
if !crate::storage::schema::table_exists(conn, table) {
continue;
}
let mut target_columns = std::collections::HashSet::new();
for row in conn.query(&format!("PRAGMA table_info({table})"))? {
if let Some(name) = row.get(1).and_then(SqliteValue::as_text) {
target_columns.insert(name.to_string());
}
}
let target_row_count = conn
.query_row(&format!("SELECT COUNT(*) FROM {table}"))?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
let drop_id = preserved.columns.iter().any(|c| c == "id") && target_row_count > 0;
let keep: Vec<usize> = preserved
.columns
.iter()
.enumerate()
.filter(|(_, name)| {
target_columns.contains(*name) && !(drop_id && *name == "id")
})
.map(|(index, _)| index)
.collect();
if keep.is_empty() {
continue;
}
let issue_id_index = preserved.columns.iter().position(|c| c == "issue_id");
let column_list = keep
.iter()
.map(|&i| preserved.columns[i].as_str())
.collect::<Vec<_>>()
.join(", ");
let placeholders = vec!["?"; keep.len()].join(", ");
let insert = format!(
"INSERT OR IGNORE INTO {table} ({column_list}) VALUES ({placeholders})"
);
let mut restored = 0usize;
let mut skipped = 0usize;
for row in &preserved.rows {
if let Some(index) = issue_id_index {
let owner = row.get(index).and_then(|v| v.as_text());
if !owner.is_some_and(|id| live_issue_ids.contains(id)) {
skipped += 1;
continue;
}
}
let params: Vec<SqliteValue> =
keep.iter().filter_map(|&i| row.get(i).cloned()).collect();
if params.len() != keep.len() {
skipped += 1;
continue;
}
restored += conn.execute_with_params(&insert, ¶ms)?;
}
report.push((preserved.table.clone(), restored, skipped));
}
Ok(())
})?;
Ok(report)
}
/// Check whether a foreign-key-backed table contains rows whose reference
/// column points at a missing issue.
///
/// Only whitelisted table/column pairs are accepted to prevent SQL injection
/// through the string-interpolated query.
///
/// # Errors
///
/// Returns an error if the query fails or the table/column pair is not
/// whitelisted.
pub(crate) fn has_missing_issue_reference(&self, table: &str, column: &str) -> Result<bool> {
const ALLOWED_PAIRS: &[(&str, &str)] = &[
("dependencies", "issue_id"),
("dependencies", "depends_on_id"),
("labels", "issue_id"),
("comments", "issue_id"),
("events", "issue_id"),
("dirty_issues", "issue_id"),
("export_hashes", "issue_id"),
("blocked_issues_cache", "issue_id"),
("child_counters", "parent_id"),
("close_metadata", "issue_id"),
("gate_result_history", "issue_id"),
("gate_results", "issue_id"),
("capacity_exemption_history", "issue_id"),
("capacity_exemptions", "issue_id"),
];
if !ALLOWED_PAIRS.contains(&(table, column)) {
return Err(crate::error::BeadsError::Config(format!(
"has_missing_issue_reference: disallowed table/column pair ({table}, {column})"
)));
}
let external_dependency_filter = match (table, column) {
("dependencies", "issue_id") => " AND issue_id NOT LIKE 'external:%'",
("dependencies", "depends_on_id") => " AND depends_on_id NOT LIKE 'external:%'",
_ => "",
};
let row = self.conn.query_row(&format!(
"SELECT COUNT(*) FROM {table} WHERE {column} NOT IN (SELECT id FROM issues){external_dependency_filter}"
))?;
Ok(row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) > 0)
}
/// Return FK-like issue references that point at missing local issues.
///
/// External dependency endpoints are intentionally allowed because the
/// schema supports cross-project blockers and external parent-child rows
/// through `external:*` IDs.
///
/// # Errors
///
/// Returns an error if any invariant query fails.
pub(crate) fn missing_issue_references(&self) -> Result<Vec<String>> {
const ISSUE_REFERENCE_PAIRS: &[(&str, &str)] = &[
("dependencies", "issue_id"),
("dependencies", "depends_on_id"),
("labels", "issue_id"),
("comments", "issue_id"),
("events", "issue_id"),
("dirty_issues", "issue_id"),
("export_hashes", "issue_id"),
("blocked_issues_cache", "issue_id"),
("child_counters", "parent_id"),
("close_metadata", "issue_id"),
("gate_result_history", "issue_id"),
("gate_results", "issue_id"),
("capacity_exemption_history", "issue_id"),
("capacity_exemptions", "issue_id"),
];
let mut violations = Vec::new();
for (table, column) in ISSUE_REFERENCE_PAIRS {
if self.has_missing_issue_reference(table, column)? {
violations.push(format!("{table}.{column}"));
}
}
Ok(violations)
}
/// Execute a raw SQL statement and return the number of affected rows.
///
/// # Errors
///
/// Returns an error if the statement fails.
pub(crate) fn execute_raw_count(&self, sql: &str) -> Result<usize> {
self.verify_attached_database_authority()?;
let rows = self.conn.execute(sql)?;
self.verify_attached_database_authority()?;
Ok(rows)
}
/// Read the schema version visible through this connection.
///
/// # Errors
///
/// Returns an error when `PRAGMA user_version` is unavailable or invalid.
pub(crate) fn schema_user_version(&self) -> Result<u32> {
connection_user_version(&self.conn).ok_or_else(|| {
BeadsError::Config(
"Could not read PRAGMA user_version for reconciliation provenance".to_string(),
)
})
}
/// Probe whether a rollback-only write against an issue can safely touch
/// the scheduling/status indexes used by update-style mutations.
///
/// This is used to distinguish a genuine on-disk corruption problem from a
/// higher-level application error after a write fails.
///
/// # Errors
///
/// Returns any database error raised while executing the probe.
pub(crate) fn probe_issue_mutation_write_path(&self, issue_id: &str) -> Result<()> {
self.verify_attached_database_authority()?;
self.conn.execute("BEGIN IMMEDIATE")?;
let probe_result = self.conn.execute_with_params(
"UPDATE issues SET priority = priority, status = status WHERE id = ?",
&[SqliteValue::from(issue_id)],
);
let rollback_result = self.conn.execute("ROLLBACK");
finish_issue_mutation_write_probe(probe_result, rollback_result)?;
self.verify_attached_database_authority()
}
/// Execute a closure against one coherent read snapshot.
///
/// This uses a deferred transaction: the first query in `f` establishes
/// the SQLite snapshot, and every later query observes that same database
/// state even if another process commits concurrently.
///
/// # Errors
///
/// Returns an error if the transaction cannot begin/commit, the closure
/// fails, or rollback after a failure also exposes a database error.
pub(crate) fn with_read_transaction<F, R>(&self, f: F) -> Result<R>
where
F: FnOnce(&Self) -> Result<R>,
{
self.verify_attached_database_authority()?;
self.conn.execute("BEGIN")?;
match f(self) {
Ok(result) => {
if let Err(authority_error) = self.verify_attached_database_authority() {
return Err(Self::rollback_transaction_error(
&self.conn,
authority_error,
"database authority changed during read transaction",
));
}
match self.conn.execute("COMMIT") {
Ok(_) => {
self.verify_attached_database_authority()?;
Ok(result)
}
Err(error) => {
let original_error = BeadsError::Database(error);
match self.conn.execute("ROLLBACK") {
Ok(_) => Err(original_error),
Err(rollback_error) => Err(Self::rollback_failure_error(
original_error,
&rollback_error,
"read-transaction COMMIT error",
)),
}
}
}
}
Err(original_error) => match self.conn.execute("ROLLBACK") {
Ok(_) => Err(original_error),
Err(rollback_error) => Err(Self::rollback_failure_error(
original_error,
&rollback_error,
"read-transaction body error",
)),
},
}
}
fn rollback_failure_error(
original_error: BeadsError,
rollback_error: &FrankenError,
cause: &str,
) -> BeadsError {
BeadsError::WithContext {
context: format!(
"ROLLBACK failed after {cause}; transaction state is unknown and no retry was attempted: {rollback_error}"
),
source: Box::new(original_error),
}
}
fn rollback_result_error(
original_error: BeadsError,
rollback_result: std::result::Result<usize, FrankenError>,
cause: &str,
) -> BeadsError {
match rollback_result {
Ok(_) => original_error,
Err(rollback_error) => {
Self::rollback_failure_error(original_error, &rollback_error, cause)
}
}
}
/// Execute a closure inside a write transaction with robust retry logic
/// for lock contention.
///
/// Retries on all transient BUSY errors (from BEGIN, DML, or COMMIT) with
/// exponential backoff.
///
/// # Errors
///
/// Returns an error if any step fails (e.g. database error, logic error).
/// The transaction is rolled back on error.
pub(crate) fn with_write_transaction<F, R>(&mut self, mut f: F) -> Result<R>
where
F: FnMut(&mut Self) -> Result<R>,
{
// Issue #219/#243: parallel agents caused "database is busy" errors
// or deadlocks. With busy_timeout=0 (see DEFAULT_BUSY_TIMEOUT_MS),
// BEGIN IMMEDIATE returns SQLITE_BUSY immediately, and all retry
// timing happens here via thread::sleep with jittered exponential
// backoff. 8 retries × (50ms → 6400ms) gives ~12.7s total wait with
// good desynchronization under concurrent access.
const MAX_RETRIES: u32 = 8;
let base_backoff_ms: u64 = 50;
let mut last_error: Option<crate::error::BeadsError> = None;
for attempt in 0..MAX_RETRIES {
self.verify_attached_database_authority()?;
match self.conn.execute("BEGIN IMMEDIATE") {
Ok(_) => {}
Err(e) if e.is_transient() && attempt < MAX_RETRIES - 1 => {
last_error = Some(e.into());
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
continue;
}
Err(e) => return Err(e.into()),
}
match f(self) {
Ok(result) => {
if let Some(authority) = self.write_authority.as_ref()
&& let Err(authority_error) = authority.verify_database_authority()
{
return Err(Self::rollback_transaction_error(
&self.conn,
authority_error,
"database authority changed before COMMIT",
));
}
match self.conn.execute("COMMIT") {
Ok(_) => {
#[cfg(test)]
self.maybe_replace_attached_database_after_commit()?;
self.verify_attached_database_authority_after_commit(
"write transaction",
)?;
// Periodic WAL checkpoint to prevent unbounded WAL growth.
// Uses PASSIVE mode so it never blocks concurrent readers
// or writers (issue #219).
self.mutation_count += 1;
if self.mutation_count >= WAL_CHECKPOINT_INTERVAL {
self.mutation_count = 0;
self.try_wal_checkpoint();
}
return Ok(result);
}
Err(e) if e.is_transient() && attempt < MAX_RETRIES - 1 => {
let commit_error = e.into();
if let Err(rollback_error) =
Self::rollback_transaction(&self.conn, "transient COMMIT error")
{
return Err(BeadsError::WithContext {
context: rollback_error,
source: Box::new(commit_error),
});
}
last_error = Some(commit_error);
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
// retry
}
Err(e) => {
return Err(Self::rollback_transaction_error(
&self.conn,
e.into(),
"COMMIT error",
));
}
}
}
Err(e) => {
if let Err(rollback_error) =
Self::rollback_transaction(&self.conn, "transaction body error")
{
return Err(BeadsError::WithContext {
context: rollback_error,
source: Box::new(e),
});
}
if e.is_transient() && attempt < MAX_RETRIES - 1 {
last_error = Some(e);
let backoff = Self::jittered_backoff(base_backoff_ms, attempt);
std::thread::sleep(Duration::from_millis(backoff));
// retry
} else {
return Err(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| {
crate::error::BeadsError::Config(
"write transaction retry loop exhausted without producing an error".into(),
)
}))
}
fn rollback_transaction(conn: &Connection, cause: &str) -> std::result::Result<(), String> {
conn.execute("ROLLBACK").map(|_| ()).map_err(|error| {
format!(
"ROLLBACK failed after {cause}; transaction state is unknown and no retry was attempted: {error}"
)
})
}
fn rollback_transaction_error(
conn: &Connection,
original_error: BeadsError,
cause: &str,
) -> BeadsError {
match Self::rollback_transaction(conn, cause) {
Ok(()) => original_error,
Err(context) => BeadsError::WithContext {
context,
source: Box::new(original_error),
},
}
}
/// Compute exponential backoff with random jitter (+/-25%) to prevent
/// thundering-herd synchronization across concurrent agents.
fn jittered_backoff(base_ms: u64, attempt: u32) -> u64 {
let deterministic = base_ms * 2u64.pow(attempt);
// Add +-25% jitter using a cheap PRNG seeded from the current time.
// No need for cryptographic randomness here.
let nanos = u64::from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos(),
);
let jitter_range = deterministic / 4;
if jitter_range == 0 {
return deterministic;
}
// Map nanos into [-jitter_range, +jitter_range) using i128 to avoid
// truncation on the u64→i64 boundary.
let raw = i128::from(nanos % (jitter_range * 2)) - i128::from(jitter_range);
let result = i128::from(deterministic) + raw;
u64::try_from(result.max(1)).unwrap_or(u64::MAX)
}
/// Set export hashes using the caller's active transaction.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn set_export_hashes_in_tx(&self, exports: &[(String, String)]) -> Result<usize> {
let unique_exports = Self::dedupe_export_hash_batch(exports);
if unique_exports.is_empty() {
return Ok(0);
}
let now = Utc::now().to_rfc3339();
let mut count = 0;
for chunk in unique_exports.chunks(EXPORT_HASH_CHUNK_SIZE) {
// Delete existing entries row-by-row to avoid fsqlite IN-clause bugs
for (id, _) in chunk {
self.conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(id.as_str())],
)?;
}
// `fsqlite` can report a false primary-key conflict when many
// existing rows are reinserted via one VALUES list, so keep each
// insert isolated after the chunk delete.
for (issue_id, content_hash) in chunk {
self.conn.execute_with_params(
"INSERT INTO export_hashes (issue_id, content_hash, exported_at) VALUES (?, ?, ?)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(now.as_str()),
],
)?;
count += 1;
}
}
Ok(count)
}
/// Insert export hashes after the caller has already cleared the table.
///
/// Import starts by deleting all export hashes, so it does not need the
/// general setter's per-row delete safety path for existing rows.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn insert_export_hashes_after_clear_in_tx(
&self,
exports: &[(String, String)],
) -> Result<usize> {
let unique_exports = Self::dedupe_export_hash_batch(exports);
if unique_exports.is_empty() {
return Ok(0);
}
let now = Utc::now().to_rfc3339();
let mut count = 0;
for (issue_id, content_hash) in &unique_exports {
self.conn.execute_with_params(
"INSERT OR REPLACE INTO export_hashes (issue_id, content_hash, exported_at) VALUES (?, ?, ?)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(now.as_str()),
],
)?;
count += 1;
}
Ok(count)
}
/// Set only export hashes whose content changed, using the caller's active transaction.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn set_changed_export_hashes_in_tx(
&self,
exports: &[(String, String)],
) -> Result<usize> {
let now = Utc::now().to_rfc3339();
self.set_changed_export_hashes_at_in_tx(exports, &now)
}
/// Set only changed export hashes at a caller-supplied, evidence-bound
/// timestamp. Reviewed recovery uses this so transaction retries and a
/// delayed apply produce the exact poststate authorized by the plan.
pub(crate) fn set_changed_export_hashes_at_in_tx(
&self,
exports: &[(String, String)],
exported_at: &str,
) -> Result<usize> {
let unique_exports = Self::dedupe_export_hash_batch(exports);
if unique_exports.is_empty() {
return Ok(0);
}
let issue_ids = unique_exports
.iter()
.map(|(issue_id, _hash)| issue_id.clone())
.collect::<Vec<_>>();
let existing_hashes = self.get_export_hashes_for_ids_in_tx(&issue_ids)?;
let mut count = 0;
for (issue_id, content_hash) in &unique_exports {
if existing_hashes
.get(issue_id)
.is_some_and(|existing_hash| existing_hash == content_hash)
{
continue;
}
self.conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(issue_id.as_str())],
)?;
self.conn.execute_with_params(
"INSERT INTO export_hashes (issue_id, content_hash, exported_at) VALUES (?, ?, ?)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(exported_at),
],
)?;
count += 1;
}
Ok(count)
}
/// Repair only persisted issue content hashes in the caller's transaction.
///
/// This intentionally leaves issue scalars, relations, `updated_at`, dirty
/// tracking, and audit events untouched.
///
/// # Errors
///
/// Returns an error if an issue disappeared or the database update fails.
pub(crate) fn repair_issue_content_hashes_in_tx(
&self,
repairs: &[(String, String)],
) -> Result<usize> {
let mut repaired = 0usize;
for (issue_id, content_hash) in repairs {
let changed = self.conn.execute_with_params(
"UPDATE issues SET content_hash = ? WHERE id = ?",
&[
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(issue_id.as_str()),
],
)?;
if changed != 1 {
return Err(BeadsError::Config(format!(
"Content-hash repair expected one issue row for '{issue_id}', updated {changed}"
)));
}
repaired = repaired.checked_add(1).ok_or_else(|| {
BeadsError::Config(
"Content-hash repair count overflow during additive reconciliation".to_string(),
)
})?;
}
Ok(repaired)
}
fn get_export_hashes_for_ids_in_tx(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, String>> {
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut hashes = HashMap::with_capacity(issue_ids.len());
for chunk in issue_ids.chunks(DIRTY_ISSUE_CHUNK_SIZE) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!(
"SELECT issue_id, content_hash FROM export_hashes WHERE issue_id IN ({})",
placeholders
);
let params = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect::<Vec<_>>();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(content_hash) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
hashes.insert(issue_id.to_string(), content_hash.to_string());
}
}
Ok(hashes)
}
fn dedupe_export_hash_batch(exports: &[(String, String)]) -> Vec<(String, String)> {
let mut deduped: Vec<(String, String)> = Vec::with_capacity(exports.len());
let mut positions: HashMap<String, usize> = HashMap::with_capacity(exports.len());
for (issue_id, content_hash) in exports {
if let Some(position) = positions.get(issue_id).copied() {
if let Some((_, stored_hash)) = deduped.get_mut(position) {
stored_hash.clone_from(content_hash);
} else {
positions.insert(issue_id.clone(), deduped.len());
deduped.push((issue_id.clone(), content_hash.clone()));
}
} else {
positions.insert(issue_id.clone(), deduped.len());
deduped.push((issue_id.clone(), content_hash.clone()));
}
}
deduped
}
/// Clear export hashes using the caller's active transaction.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn clear_export_hashes_in_tx(&self, issue_ids: &[String]) -> Result<usize> {
if issue_ids.is_empty() {
return Ok(0);
}
let mut total_deleted = 0;
for chunk in issue_ids.chunks(EXPORT_HASH_CHUNK_SIZE) {
// Delete existing entries row-by-row to avoid fsqlite IN-clause bugs
let mut chunk_deleted = 0;
for id in chunk {
let deleted = self.conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(id.as_str())],
)?;
chunk_deleted += deleted;
}
total_deleted += chunk_deleted;
}
Ok(total_deleted)
}
/// Attempt a WAL checkpoint (PASSIVE mode) to flush WAL back to the main
/// database file. Errors are logged but do not propagate — checkpoint
/// failure is non-fatal and will be retried on the next interval.
fn try_wal_checkpoint(&mut self) {
// Issue #219: TRUNCATE mode requires an exclusive lock, which blocks
// all concurrent readers and writers. Under parallel agent operations
// this was a major source of "database is busy" errors. PASSIVE mode
// checkpoints only pages that are not currently needed by any reader,
// so it never blocks other connections. The WAL file may grow slightly
// larger between checkpoints, but journal_size_limit (set in
// apply_runtime_pragmas) caps it.
let hold = match self.admit_checkpoint() {
CheckpointAdmission::Sole(hold) => hold,
CheckpointAdmission::PeersPresent => {
tracing::debug!(
"Skipping periodic WAL checkpoint: another process has the database open"
);
return;
}
};
self.passive_checkpoint_as_sole_opener();
self.release_checkpoint_admission(hold);
}
fn passive_checkpoint_as_sole_opener(&self) {
if let Err(e) = self.verify_attached_database_authority() {
tracing::warn!(error = %e, "Skipping WAL checkpoint after database authority changed");
return;
}
if let Err(e) = self.conn.execute("PRAGMA wal_checkpoint(PASSIVE)") {
tracing::debug!(error = %e, "WAL checkpoint failed (non-fatal, will retry later)");
} else if let Err(e) = self.verify_attached_database_authority() {
tracing::warn!(error = %e, "Database authority changed during WAL checkpoint");
}
}
/// Prove this process is the only opener of the persistent database
/// before a WAL checkpoint.
///
/// FrankenSQLite's multi-process checkpoint does not yet register against
/// peer processes' read snapshots (FrankenSQLite #399/#385), so a
/// checkpoint run while another `br` has the database open is the
/// interleaving behind the page-aliasing corruption in GitHub
/// #457/#460/#461. Ephemeral databases have no peers and are always
/// admitted.
fn admit_checkpoint(&mut self) -> CheckpointAdmission {
match self.opener_lease.as_mut() {
None => CheckpointAdmission::Sole(None),
Some(lease) => lease
.try_exclusive()
.map_or(CheckpointAdmission::PeersPresent, |hold| {
CheckpointAdmission::Sole(Some(hold))
}),
}
}
/// Hand back the exclusive opener hold taken by [`Self::admit_checkpoint`]
/// and rejoin the shared opener registration.
fn release_checkpoint_admission(&mut self, hold: Option<std::fs::File>) {
if let (Some(lease), Some(hold)) = (self.opener_lease.as_mut(), hold) {
lease.release_exclusive(hold);
}
}
/// Force a full WAL checkpoint that drains every pending frame back into
/// the main database file.
///
/// Called at quiescent points (post-rebuild, before VACUUM/REINDEX) where
/// we hold the `.write.lock` exclusively and can safely take the DB's
/// internal exclusive lock that TRUNCATE mode requires. The passive
/// checkpoint used during normal mutation can leave WAL frames behind
/// that VACUUM/REINDEX later trip over ("database is busy (snapshot
/// conflict on pages: page N > snapshot db_size M)"), so we need a
/// stronger guarantee here.
///
/// # Errors
///
/// Returns an error when another process has the database open (no
/// checkpoint is attempted at all; see [`Self::admit_checkpoint`]) or if
/// even a PASSIVE checkpoint fails. TRUNCATE failure is downgraded to a
/// warning because it is best-effort.
pub(crate) fn checkpoint_full(&mut self) -> Result<()> {
let hold = match self.admit_checkpoint() {
CheckpointAdmission::Sole(hold) => hold,
CheckpointAdmission::PeersPresent => {
return Err(BeadsError::Config(
"WAL checkpoint skipped: another br process has the database open \
(FrankenSQLite checkpoints are only safe for a sole opener)"
.to_string(),
));
}
};
let result = self.checkpoint_full_as_sole_opener();
self.release_checkpoint_admission(hold);
result
}
fn checkpoint_full_as_sole_opener(&self) -> Result<()> {
self.verify_attached_database_authority()?;
if let Err(e) = self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") {
tracing::debug!(
error = %e,
"TRUNCATE checkpoint failed; falling back to PASSIVE"
);
self.conn.execute("PRAGMA wal_checkpoint(PASSIVE)")?;
}
self.verify_attached_database_authority()
}
/// Run SQLite's native integrity probe and return its diagnostic rows.
///
/// # Errors
///
/// Returns an error if the pragma cannot be executed.
pub(crate) fn integrity_check_messages(&self) -> Result<Vec<String>> {
self.database_check_messages("PRAGMA integrity_check")
}
/// Run SQLite's structural check without its page-ownership scan.
///
/// This is the in-transaction companion to [`Self::integrity_check_messages`].
/// FrankenSQLite's full integrity walker switches to a transaction-local
/// freelist projection while a transaction is active; a read transaction
/// over a healthy database with committed free pages can therefore report
/// a false orphan. `quick_check` still validates every B-tree page and is
/// safe at the transaction boundary. Callers must run the full integrity
/// check again from autocommit state after the transaction ends.
///
/// # Errors
///
/// Returns an error if the pragma cannot be executed.
pub(crate) fn quick_check_messages(&self) -> Result<Vec<String>> {
self.database_check_messages("PRAGMA quick_check")
}
fn database_check_messages(&self, pragma: &str) -> Result<Vec<String>> {
let rows = self.conn.query(pragma)?;
let mut messages = Vec::new();
for row in rows {
for value in row.values() {
if let Some(text) = value.as_text() {
let trimmed = text.trim();
if !trimmed.is_empty() {
messages.push(trimmed.to_string());
}
}
}
}
if messages.is_empty() {
messages.push("integrity_check returned no diagnostic rows".to_string());
}
Ok(messages)
}
/// Return raw rows from SQLite's foreign-key consistency probe.
///
/// An empty vector is the only healthy result. The textual row projection
/// is intentionally retained so reconciliation receipts can diagnose the
/// exact table/row/parent constraint that failed.
///
/// # Errors
///
/// Returns an error if the pragma cannot be executed.
pub(crate) fn foreign_key_check_messages(&self) -> Result<Vec<Vec<String>>> {
let rows = self.conn.query("PRAGMA foreign_key_check")?;
Ok(rows
.into_iter()
.map(|row| {
row.values()
.iter()
.map(|value| {
value.as_text().map_or_else(
|| {
value.as_integer().map_or_else(
|| format!("{value:?}"),
|number| number.to_string(),
)
},
str::to_string,
)
})
.collect()
})
.collect())
}
/// Get audit events for a specific issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_events(&self, issue_id: &str, limit: usize) -> Result<Vec<Event>> {
crate::storage::events::get_events(&self.conn, issue_id, limit)
}
/// Get all audit events (for summary).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_events(&self, limit: usize) -> Result<Vec<Event>> {
crate::storage::events::get_all_events(&self.conn, limit)
}
/// Find the actor who most recently transitioned `issue_id` into the
/// `in_progress` state. Returns `None` when the issue never had such a
/// transition recorded — typical of issues that went from `open` to
/// `closed` without a claim step.
///
/// Used by the closure-time `forbid_self_close_after_in_progress` policy
/// gate (issue #274 Phase 1).
///
/// # Errors
///
/// Returns an error if the underlying event query fails.
pub fn find_last_in_progress_actor(&self, issue_id: &str) -> Result<Option<String>> {
let events = crate::storage::events::get_events(&self.conn, issue_id, 0)?;
// get_events returns DESC ordering by created_at then id, so the first
// matching event is the most recent transition into in_progress.
for event in events {
if event.event_type == crate::model::EventType::StatusChanged
&& event
.new_value
.as_deref()
.map(str::trim)
.is_some_and(|v| v.eq_ignore_ascii_case("in_progress"))
{
let actor = event.actor.trim();
if actor.is_empty() {
return Ok(None);
}
return Ok(Some(actor.to_string()));
}
}
Ok(None)
}
/// Persist closure-time policy metadata (issue #274 Phase 1) for `issue_id`.
///
/// Inserts (or replaces) one row in the `close_metadata` table with the
/// supplied attribution + bypass auditing values. All fields are optional:
/// passing every-`None` still records a row that pins `bypassed_policy = 0`
/// for the close, which keeps the table strictly additive — every close
/// performed under an active policy is queryable later. Callers decide
/// whether policy metadata is active enough to warrant a row.
///
/// `policy_gates_fired` is stored as the JSON serialisation of the gate
/// names that fired (or that were waived by `--bypass-policy`). An empty
/// slice serialises to `"[]"` so callers can always rely on JSON typing.
///
/// # Errors
///
/// Returns an error if the database write fails or JSON serialisation of
/// `policy_gates_fired` fails.
pub fn record_close_metadata(
&self,
issue_id: &str,
attribution: &crate::close_policy::AttributionValues,
bypassed: bool,
bypass_reason: Option<&str>,
policy_gates_fired: &[String],
) -> Result<()> {
let gates_json = serde_json::to_string(policy_gates_fired).map_err(BeadsError::from)?;
// INSERT OR REPLACE: a re-close (e.g. close → reopen → close) overwrites
// the prior row. If the project ever needs full history, querying the
// events table gives an audit trail; `close_metadata` is the
// currently-effective metadata for the most recent close.
self.with_connection_write_transaction(|conn| {
conn.execute_with_params(
"INSERT OR REPLACE INTO close_metadata (
issue_id,
closed_by_agent_name,
closed_by_harness,
closed_by_model,
bypassed_policy,
bypass_reason,
policy_gates_fired,
recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
attribution
.agent_name
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
attribution
.harness
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
attribution
.model
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(i64::from(bypassed)),
bypass_reason.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(gates_json.as_str()),
],
)?;
Ok(())
})
}
/// Read a previously-stored close-metadata row, or `None` when no policy
/// metadata was recorded for this close. Used by tests + future
/// observability commands.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_close_metadata(&self, issue_id: &str) -> Result<Option<CloseMetadataRow>> {
if !crate::storage::schema::table_exists(&self.conn, "close_metadata") {
return Ok(None);
}
let rows = self.conn.query_with_params(
"SELECT closed_by_agent_name, closed_by_harness, closed_by_model, \
bypassed_policy, bypass_reason, policy_gates_fired, recorded_at \
FROM close_metadata WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let Some(row) = rows.first() else {
return Ok(None);
};
let bypassed = row
.get(3)
.and_then(SqliteValue::as_integer)
.unwrap_or_default()
!= 0;
let gates_json: Option<String> =
row.get(5).and_then(SqliteValue::as_text).map(String::from);
let policy_gates_fired = match gates_json.as_deref() {
Some(json) if !json.is_empty() => {
serde_json::from_str::<Vec<String>>(json).map_err(BeadsError::from)?
}
_ => Vec::new(),
};
Ok(Some(CloseMetadataRow {
closed_by_agent_name: row.get(0).and_then(SqliteValue::as_text).map(String::from),
closed_by_harness: row.get(1).and_then(SqliteValue::as_text).map(String::from),
closed_by_model: row.get(2).and_then(SqliteValue::as_text).map(String::from),
bypassed_policy: bypassed,
bypass_reason: row.get(4).and_then(SqliteValue::as_text).map(String::from),
policy_gates_fired,
recorded_at: row
.get(6)
.and_then(SqliteValue::as_text)
.map(String::from)
.unwrap_or_default(),
}))
}
/// Append a workflow-gate verdict for the issue's current status revision
/// and one explicit target transition (GitHub #388).
///
/// The current status and latest `status_changed` event id are read inside
/// the same `BEGIN IMMEDIATE` transaction as the insert and must still
/// match the caller's expected scope. A concurrent transition therefore
/// rejects the report instead of making its verdict land on the wrong
/// review cycle.
///
/// # Errors
///
/// Returns an error if the issue is missing or the database write fails.
#[allow(clippy::too_many_arguments)]
pub fn record_scoped_gate_result(
&self,
issue_id: &str,
expected_from_status: &str,
expected_status_revision: i64,
to_status: &str,
gate: &str,
provider: &str,
passed: bool,
note: Option<&str>,
recorded_by: &str,
) -> Result<crate::close_policy::GateResultRecord> {
let to_status = to_status.trim().to_ascii_lowercase();
let gate = gate.trim();
let provider = provider.trim();
if to_status.is_empty() {
return Err(BeadsError::validation(
"to",
"gate target status must not be empty",
));
}
if gate.is_empty() {
return Err(BeadsError::validation(
"gate",
"gate name must not be empty",
));
}
if provider.is_empty() {
return Err(BeadsError::validation(
"provider",
"gate provider must not be empty",
));
}
let mut recorded = None;
self.with_connection_write_transaction(|conn| {
let issue = Self::get_issue_from_conn(conn, issue_id)?.ok_or_else(|| {
BeadsError::IssueNotFound {
id: issue_id.to_string(),
}
})?;
let from_status = issue.status.as_str().trim().to_ascii_lowercase();
let status_revision = Self::status_revision_in_tx(conn, issue_id)?;
if !from_status.eq_ignore_ascii_case(expected_from_status.trim())
|| status_revision != expected_status_revision
{
return Err(BeadsError::validation(
"gate_scope",
format!(
"issue {issue_id} changed from status '{}' revision {} to status '{}' revision {} while the gate report was prepared; retry against the current transition attempt",
expected_from_status.trim(),
expected_status_revision,
from_status,
status_revision,
),
));
}
conn.execute_with_params(
"INSERT INTO gate_result_history (
issue_id, from_status, to_status, status_revision, gate,
provider, passed, note, recorded_by, recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(from_status.as_str()),
SqliteValue::from(to_status.as_str()),
SqliteValue::from(status_revision),
SqliteValue::from(gate),
SqliteValue::from(provider),
SqliteValue::from(i64::from(passed)),
note.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(recorded_by),
],
)?;
let row = conn.query_row("SELECT last_insert_rowid()")?;
let id = row
.get(0)
.and_then(SqliteValue::as_integer)
.ok_or_else(|| {
BeadsError::Config(
"gate-result insert did not return last_insert_rowid".to_string(),
)
})?;
let rows = conn.query_with_params(
"SELECT id, issue_id, from_status, to_status, status_revision,
gate, provider, passed, note, recorded_by, recorded_at
FROM gate_result_history WHERE id = ?",
&[SqliteValue::from(id)],
)?;
let row = rows.first().ok_or_else(|| {
BeadsError::Config(format!("gate-result history row {id} missing after insert"))
})?;
recorded = Some(gate_result_record_from_row(row)?);
// GitHub #466: mirror the verdict into the current-state
// `gate_results` table. One row per (issue, gate, provider) with
// the provider's most-recent verdict; a re-report overwrites the
// prior row exactly as the schema comment promises. Transition
// enforcement never reads this table (it consults the scoped
// history above); the mirror keeps the documented current-state
// view populated for external queries. Runs after the
// last_insert_rowid() capture so it cannot disturb the history
// row lookup.
conn.execute_with_params(
"INSERT OR REPLACE INTO gate_results (
issue_id, gate, provider, passed, note, recorded_by, recorded_at
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(gate),
SqliteValue::from(provider),
SqliteValue::from(i64::from(passed)),
note.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(recorded_by),
],
)?;
Ok(())
})?;
recorded.ok_or_else(|| {
BeadsError::internal("gate-result transaction committed without a result row")
})
}
fn status_revision_in_tx(conn: &Connection, issue_id: &str) -> Result<i64> {
let rows = conn.query_with_params(
"SELECT id FROM events
WHERE issue_id = ? AND event_type = 'status_changed'
ORDER BY id DESC LIMIT 1",
&[SqliteValue::from(issue_id)],
)?;
Ok(rows
.first()
.and_then(|row| row.get(0))
.and_then(SqliteValue::as_integer)
.unwrap_or(0))
}
/// Return the current status-revision id for an issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn status_revision(&self, issue_id: &str) -> Result<i64> {
Self::status_revision_in_tx(&self.conn, issue_id)
}
/// Return the latest verdict from each `(gate, provider)` for an exact
/// issue/from/to/current-revision scope. Earlier verdicts remain in the
/// append-only history but are not effective.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_scoped_gate_results(
&self,
issue_id: &str,
from_status: &str,
to_status: &str,
) -> Result<Vec<crate::close_policy::GateResult>> {
let status_revision = self.status_revision(issue_id)?;
let from_status = from_status.trim().to_ascii_lowercase();
let to_status = to_status.trim().to_ascii_lowercase();
Self::get_scoped_gate_results_in_tx(
&self.conn,
issue_id,
&from_status,
&to_status,
status_revision,
)
}
fn get_scoped_gate_results_in_tx(
conn: &Connection,
issue_id: &str,
from_status: &str,
to_status: &str,
status_revision: i64,
) -> Result<Vec<crate::close_policy::GateResult>> {
if !crate::storage::schema::table_exists(conn, "gate_result_history") {
return Ok(Vec::new());
}
let from_status = from_status.trim().to_ascii_lowercase();
let to_status = to_status.trim().to_ascii_lowercase();
let rows = conn.query_with_params(
"SELECT gate, provider, passed, note
FROM gate_result_history
WHERE issue_id = ? AND from_status = ? AND to_status = ?
AND status_revision = ?
ORDER BY id ASC",
&[
SqliteValue::from(issue_id),
SqliteValue::from(from_status.as_str()),
SqliteValue::from(to_status.as_str()),
SqliteValue::from(status_revision),
],
)?;
let mut effective = BTreeMap::new();
for row in &rows {
let Some(gate) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(provider) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let result = crate::close_policy::GateResult {
gate: gate.to_string(),
provider: provider.to_string(),
passed: row
.get(2)
.and_then(SqliteValue::as_integer)
.unwrap_or_default()
!= 0,
note: row.get(3).and_then(SqliteValue::as_text).map(String::from),
};
effective.insert(
(gate.to_ascii_lowercase(), provider.to_ascii_lowercase()),
result,
);
}
Ok(effective.into_values().collect())
}
fn prior_satisfying_gate_revisions_in_tx(
conn: &Connection,
issue_id: &str,
from_status: &str,
to_status: &str,
current_revision: i64,
spec: &crate::close_policy::GateSpec,
) -> Result<Vec<i64>> {
let from_status = from_status.trim().to_ascii_lowercase();
let to_status = to_status.trim().to_ascii_lowercase();
let rows = conn.query_with_params(
"SELECT status_revision, gate, provider, passed, note
FROM gate_result_history
WHERE issue_id = ? AND from_status = ? AND to_status = ?
AND status_revision != ?
ORDER BY id ASC",
&[
SqliteValue::from(issue_id),
SqliteValue::from(from_status.as_str()),
SqliteValue::from(to_status.as_str()),
SqliteValue::from(current_revision),
],
)?;
let mut by_revision =
BTreeMap::<i64, BTreeMap<(String, String), crate::close_policy::GateResult>>::new();
for row in &rows {
let Some(revision) = row.get(0).and_then(SqliteValue::as_integer) else {
continue;
};
let Some(gate) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let Some(provider) = row.get(2).and_then(SqliteValue::as_text) else {
continue;
};
by_revision.entry(revision).or_default().insert(
(gate.to_ascii_lowercase(), provider.to_ascii_lowercase()),
crate::close_policy::GateResult {
gate: gate.to_string(),
provider: provider.to_string(),
passed: row
.get(3)
.and_then(SqliteValue::as_integer)
.unwrap_or_default()
!= 0,
note: row.get(4).and_then(SqliteValue::as_text).map(String::from),
},
);
}
Ok(by_revision
.into_iter()
.filter_map(|(revision, results)| {
let effective = results.into_values().collect::<Vec<_>>();
crate::close_policy::gate_spec_satisfied(spec, &effective).then_some(revision)
})
.collect())
}
/// Return append-only scoped gate history for an issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_gate_result_history(
&self,
issue_id: &str,
) -> Result<Vec<crate::close_policy::GateResultRecord>> {
if !crate::storage::schema::table_exists(&self.conn, "gate_result_history") {
return Ok(Vec::new());
}
let rows = self.conn.query_with_params(
"SELECT id, issue_id, from_status, to_status, status_revision,
gate, provider, passed, note, recorded_by, recorded_at
FROM gate_result_history WHERE issue_id = ? ORDER BY id ASC",
&[SqliteValue::from(issue_id)],
)?;
rows.iter().map(gate_result_record_from_row).collect()
}
/// Return pre-v15 unscoped gate rows for audit display only. These rows
/// are never consulted by transition enforcement.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_legacy_gate_results(
&self,
issue_id: &str,
) -> Result<Vec<crate::close_policy::GateResult>> {
if !crate::storage::schema::table_exists(&self.conn, "gate_results") {
return Ok(Vec::new());
}
let rows = self.conn.query_with_params(
"SELECT gate, provider, passed, note FROM gate_results
WHERE issue_id = ? ORDER BY gate, provider",
&[SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|row| {
Some(crate::close_policy::GateResult {
gate: row.get(0).and_then(SqliteValue::as_text)?.to_string(),
provider: row.get(1).and_then(SqliteValue::as_text)?.to_string(),
passed: row
.get(2)
.and_then(SqliteValue::as_integer)
.unwrap_or_default()
!= 0,
note: row.get(3).and_then(SqliteValue::as_text).map(String::from),
})
})
.collect())
}
/// Validate one grant/renew request against the installed capacity
/// policy and return the canonical `(kind, name)` pair to store.
///
/// GitHub #384: "Unauthorized, expired, or reasonless exemptions fail" —
/// the checks here are the grant-time half; enforcement re-filters by
/// provider so a later policy edit withdraws recorded grants too.
/// True when any capacity scope declares a limit for this canonical
/// status/group name (GitHub #384 phase 5): a capacity limited only
/// within a scope is still a real capacity an exemption can free.
fn capacity_name_is_scoped(
policy: &crate::close_policy::CapacityPolicy,
kind: &str,
name: &str,
) -> bool {
policy.scopes.values().any(|scope| {
let keys: Vec<&String> = if kind == "status" {
scope.statuses.keys().collect()
} else {
scope.groups.keys().collect()
};
keys.into_iter()
.any(|candidate| candidate.trim().eq_ignore_ascii_case(name))
})
}
fn validate_capacity_exemption_request(
&self,
capacity_kind: &str,
capacity_name: &str,
provider: &str,
expires_at: Option<chrono::DateTime<Utc>>,
) -> Result<(String, String)> {
let policy = &self.workflow_capacity_policy;
if !policy.exemptions.is_enabled() {
return Err(BeadsError::validation(
"workflow.capacity.exemptions",
"capacity exemptions are not enabled: list authorized providers under \
workflow.capacity.exemptions.providers in .beads/policy.yaml",
));
}
let provider = provider.trim();
if !policy.exemptions.authorizes(provider) {
return Err(BeadsError::validation(
"provider",
format!(
"provider '{provider}' is not authorized to manage capacity exemptions; \
authorized providers: {}",
policy.exemptions.providers.join(", ")
),
));
}
let kind = capacity_kind.trim().to_lowercase();
let name = capacity_name.trim().to_lowercase();
if name.is_empty() {
return Err(BeadsError::validation(
"capacity",
"capacity name must not be empty",
));
}
match kind.as_str() {
"status" => {
let named_by_limit = policy
.statuses
.keys()
.any(|status| status.trim().eq_ignore_ascii_case(&name));
let named_by_admission = policy.admission.iter().any(|rule| {
rule.require_below
.statuses
.keys()
.any(|status| status.trim().eq_ignore_ascii_case(&name))
});
let named_by_scope = Self::capacity_name_is_scoped(policy, "status", &name);
if !named_by_limit && !named_by_admission && !named_by_scope {
return Err(BeadsError::validation(
"capacity",
format!(
"status '{name}' has no configured capacity limit and is not \
observed by any admission rule; an exemption from it would \
have no effect"
),
));
}
}
"group" => {
let named_by_scope = Self::capacity_name_is_scoped(policy, "group", &name);
if Self::capacity_group(policy, &name).is_none() && !named_by_scope {
return Err(BeadsError::validation(
"capacity",
format!(
"capacity group '{name}' is not configured in workflow.capacity.groups"
),
));
}
}
other => {
return Err(BeadsError::validation(
"capacity",
format!("capacity kind '{other}' must be 'status' or 'group'"),
));
}
}
if policy.exemptions.require_expiry && expires_at.is_none() {
return Err(BeadsError::validation(
"expires",
"policy requires every capacity exemption to carry an expiration \
(workflow.capacity.exemptions.require_expiry)",
));
}
if let Some(expiry) = expires_at {
let now = Utc::now();
if expiry <= now {
return Err(BeadsError::validation(
"expires",
"capacity exemption expiry must be in the future",
));
}
if let Some(max_ttl) = policy.exemptions.max_ttl_seconds {
let horizon =
now + chrono::Duration::seconds(i64::try_from(max_ttl).unwrap_or(i64::MAX));
if expiry > horizon {
return Err(BeadsError::validation(
"expires",
format!(
"capacity exemption expiry exceeds the policy maximum of \
{max_ttl} seconds from now \
(workflow.capacity.exemptions.max_ttl_seconds)"
),
));
}
}
}
Ok((kind, name))
}
/// Read the state row for one `(issue, capacity)` exemption and derive
/// its display state at `now`. Listing never mutates: expiry is only
/// *marked* inside enforcement and mutating exemption commands.
fn capacity_exemption_record_from_conn(
conn: &Connection,
issue_id: &str,
kind: &str,
name: &str,
now: chrono::DateTime<Utc>,
) -> Result<Option<crate::close_policy::CapacityExemptionRecord>> {
let rows = conn.query_with_params(
"SELECT issue_id, capacity_kind, capacity_name, provider, reason,
granted_by, granted_at, expires_at, ended_at, ended_action, ended_by
FROM capacity_exemptions
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind),
SqliteValue::from(name),
],
)?;
Ok(rows
.first()
.and_then(|row| Self::capacity_exemption_record_from_row(row, now)))
}
fn capacity_exemption_record_from_row(
row: &Row,
now: chrono::DateTime<Utc>,
) -> Option<crate::close_policy::CapacityExemptionRecord> {
let text = |index: usize| {
row.get(index)
.and_then(SqliteValue::as_text)
.map(String::from)
};
let expires_at = text(7);
let ended_action = text(9);
let state = ended_action.as_deref().map_or_else(
|| {
let has_expired = expires_at.as_deref().is_some_and(|raw| {
chrono::DateTime::parse_from_rfc3339(raw)
.map_or(true, |dt| dt.with_timezone(&Utc) <= now)
});
if has_expired { "expired" } else { "active" }.to_string()
},
|action| match action {
"revoked" | "expired" | "left_status" => action.to_string(),
other => other.to_string(),
},
);
Some(crate::close_policy::CapacityExemptionRecord {
issue_id: text(0)?,
capacity_kind: text(1)?,
capacity_name: text(2)?,
provider: text(3)?,
reason: text(4).unwrap_or_default(),
granted_by: text(5).unwrap_or_default(),
granted_at: text(6).unwrap_or_default(),
expires_at,
ended_at: text(8),
ended_action,
ended_by: text(10),
state,
})
}
/// Grant (or re-grant) an audited issue-specific capacity exemption
/// (GitHub #384 phase 4). A re-grant replaces the state row; every
/// action lands in the append-only history table.
///
/// # Errors
///
/// Returns a validation error when exemptions are disabled, the
/// provider is unauthorized, the capacity is not configured, the reason
/// is empty, or the expiry violates policy; `IssueNotFound` when the
/// issue does not exist.
#[allow(clippy::too_many_arguments)]
pub fn grant_capacity_exemption(
&self,
issue_id: &str,
capacity_kind: &str,
capacity_name: &str,
provider: &str,
reason: &str,
expires_at: Option<chrono::DateTime<Utc>>,
actor: &str,
) -> Result<crate::close_policy::CapacityExemptionRecord> {
let reason = reason.trim();
if reason.is_empty() {
return Err(BeadsError::validation(
"reason",
"a capacity exemption requires a non-empty --reason",
));
}
let (kind, name) = self.validate_capacity_exemption_request(
capacity_kind,
capacity_name,
provider,
expires_at,
)?;
let provider = provider.trim().to_string();
let expires_text = expires_at.map(|dt| dt.to_rfc3339());
let mut record = None;
self.with_connection_write_transaction(|conn| {
if Self::get_issue_from_conn(conn, issue_id)?.is_none() {
return Err(BeadsError::IssueNotFound {
id: issue_id.to_string(),
});
}
conn.execute_with_params(
"INSERT OR REPLACE INTO capacity_exemptions (
issue_id, capacity_kind, capacity_name, provider, reason,
granted_by, granted_at, expires_at, ended_at, ended_action, ended_by
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, NULL, NULL, NULL)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
SqliteValue::from(provider.as_str()),
SqliteValue::from(reason),
SqliteValue::from(actor),
expires_text
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'grant', ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
SqliteValue::from(provider.as_str()),
SqliteValue::from(reason),
SqliteValue::from(actor),
expires_text
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
record = Self::capacity_exemption_record_from_conn(
conn,
issue_id,
&kind,
&name,
Utc::now(),
)?;
Ok(())
})?;
record.ok_or_else(|| {
BeadsError::internal("capacity exemption grant committed without a state row")
})
}
/// Renew an active exemption's expiry (GitHub #384 phase 4). An expired
/// or ended exemption cannot be renewed — grant a new one, so the audit
/// trail shows the gap.
///
/// # Errors
///
/// Returns a validation error when the exemption is missing, ended, or
/// expired, or when the provider/expiry violates policy.
#[allow(clippy::too_many_arguments)]
pub fn renew_capacity_exemption(
&self,
issue_id: &str,
capacity_kind: &str,
capacity_name: &str,
provider: &str,
reason: Option<&str>,
expires_at: Option<chrono::DateTime<Utc>>,
actor: &str,
) -> Result<crate::close_policy::CapacityExemptionRecord> {
let (kind, name) = self.validate_capacity_exemption_request(
capacity_kind,
capacity_name,
provider,
expires_at,
)?;
let provider = provider.trim().to_string();
let expires_text = expires_at.map(|dt| dt.to_rfc3339());
let reason = reason.map(str::trim).filter(|value| !value.is_empty());
let mut record = None;
self.with_connection_write_transaction(|conn| {
let now = Utc::now();
let existing =
Self::capacity_exemption_record_from_conn(conn, issue_id, &kind, &name, now)?
.ok_or_else(|| {
BeadsError::validation(
"capacity",
format!(
"no capacity exemption exists for {issue_id} on {kind} '{name}'; \
grant one first"
),
)
})?;
if existing.state != "active" {
Self::mark_capacity_exemption_expired_if_needed(conn, &existing)?;
return Err(BeadsError::validation(
"capacity",
format!(
"capacity exemption for {issue_id} on {kind} '{name}' is {}; \
grant a new exemption instead of renewing",
existing.state
),
));
}
conn.execute_with_params(
"UPDATE capacity_exemptions SET provider = ?, expires_at = ?
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?
AND ended_at IS NULL",
&[
SqliteValue::from(provider.as_str()),
expires_text
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'renew', ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
SqliteValue::from(provider.as_str()),
reason.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(actor),
expires_text
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
record = Self::capacity_exemption_record_from_conn(
conn,
issue_id,
&kind,
&name,
Utc::now(),
)?;
Ok(())
})?;
record.ok_or_else(|| {
BeadsError::internal("capacity exemption renewal committed without a state row")
})
}
/// Mark an observed-expired exemption ended with an audited `expire`
/// history record. No-op when the record is not in the derived
/// `expired` state or is already ended.
fn mark_capacity_exemption_expired_if_needed(
conn: &Connection,
record: &crate::close_policy::CapacityExemptionRecord,
) -> Result<()> {
if record.state != "expired" || record.ended_at.is_some() {
return Ok(());
}
conn.execute_with_params(
"UPDATE capacity_exemptions
SET ended_at = CURRENT_TIMESTAMP, ended_action = 'expired', ended_by = 'system'
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?
AND ended_at IS NULL",
&[
SqliteValue::from(record.issue_id.as_str()),
SqliteValue::from(record.capacity_kind.as_str()),
SqliteValue::from(record.capacity_name.as_str()),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'expire', ?, ?, 'system', ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(record.issue_id.as_str()),
SqliteValue::from(record.capacity_kind.as_str()),
SqliteValue::from(record.capacity_name.as_str()),
SqliteValue::from(record.provider.as_str()),
SqliteValue::from("expiration observed during exemption management"),
record
.expires_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
Ok(())
}
/// Revoke an active exemption (GitHub #384 phase 4). Revocation is
/// deliberately *not* provider-gated: withdrawing privilege must stay
/// possible even after policy edits; the provider and actor are still
/// recorded for audit.
///
/// # Errors
///
/// Returns a validation error when the exemption is missing or already
/// ended/expired.
pub fn revoke_capacity_exemption(
&self,
issue_id: &str,
capacity_kind: &str,
capacity_name: &str,
provider: &str,
reason: Option<&str>,
actor: &str,
) -> Result<crate::close_policy::CapacityExemptionRecord> {
let kind = capacity_kind.trim().to_lowercase();
let name = capacity_name.trim().to_lowercase();
let provider = provider.trim().to_string();
let reason = reason.map(str::trim).filter(|value| !value.is_empty());
let mut record = None;
self.with_connection_write_transaction(|conn| {
let now = Utc::now();
let existing =
Self::capacity_exemption_record_from_conn(conn, issue_id, &kind, &name, now)?
.ok_or_else(|| {
BeadsError::validation(
"capacity",
format!(
"no capacity exemption exists for {issue_id} on {kind} '{name}'"
),
)
})?;
if existing.state != "active" {
Self::mark_capacity_exemption_expired_if_needed(conn, &existing)?;
return Err(BeadsError::validation(
"capacity",
format!(
"capacity exemption for {issue_id} on {kind} '{name}' is already {}",
existing.state
),
));
}
conn.execute_with_params(
"UPDATE capacity_exemptions
SET ended_at = CURRENT_TIMESTAMP, ended_action = 'revoked', ended_by = ?
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?
AND ended_at IS NULL",
&[
SqliteValue::from(actor),
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'revoke', ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
SqliteValue::from(provider.as_str()),
reason.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(actor),
existing
.expires_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
record = Self::capacity_exemption_record_from_conn(
conn,
issue_id,
&kind,
&name,
Utc::now(),
)?;
Ok(())
})?;
record.ok_or_else(|| {
BeadsError::internal("capacity exemption revocation committed without a state row")
})
}
/// List exemption state rows, optionally restricted to one issue, with
/// display state derived at read time (never mutating).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_capacity_exemptions(
&self,
issue_id: Option<&str>,
) -> Result<Vec<crate::close_policy::CapacityExemptionRecord>> {
if !crate::storage::schema::table_exists(&self.conn, "capacity_exemptions") {
return Ok(Vec::new());
}
let base = "SELECT issue_id, capacity_kind, capacity_name, provider, reason,
granted_by, granted_at, expires_at, ended_at, ended_action, ended_by
FROM capacity_exemptions";
let rows = if let Some(issue_id) = issue_id {
self.conn.query_with_params(
&format!(
"{base} WHERE issue_id = ? ORDER BY issue_id, capacity_kind, capacity_name"
),
&[SqliteValue::from(issue_id)],
)?
} else {
self.conn.query(&format!(
"{base} ORDER BY issue_id, capacity_kind, capacity_name"
))?
};
let now = Utc::now();
Ok(rows
.iter()
.filter_map(|row| Self::capacity_exemption_record_from_row(row, now))
.collect())
}
/// Append-only exemption history, optionally restricted to one issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_capacity_exemption_history(
&self,
issue_id: Option<&str>,
) -> Result<Vec<crate::close_policy::CapacityExemptionHistoryRecord>> {
if !crate::storage::schema::table_exists(&self.conn, "capacity_exemption_history") {
return Ok(Vec::new());
}
let base = "SELECT id, issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
FROM capacity_exemption_history";
let rows = if let Some(issue_id) = issue_id {
self.conn.query_with_params(
&format!("{base} WHERE issue_id = ? ORDER BY id"),
&[SqliteValue::from(issue_id)],
)?
} else {
self.conn.query(&format!("{base} ORDER BY id"))?
};
Ok(rows
.iter()
.filter_map(|row| {
let text = |index: usize| {
row.get(index)
.and_then(SqliteValue::as_text)
.map(String::from)
};
Some(crate::close_policy::CapacityExemptionHistoryRecord {
id: row.get(0).and_then(SqliteValue::as_integer)?,
issue_id: text(1)?,
capacity_kind: text(2)?,
capacity_name: text(3)?,
action: text(4)?,
provider: text(5).unwrap_or_default(),
reason: text(6),
actor: text(7).unwrap_or_default(),
expires_at: text(8),
recorded_at: text(9).unwrap_or_default(),
})
})
.collect())
}
/// Load the active, authorized capacity exemptions consulted by one
/// enforcement call (GitHub #384 phase 4).
///
/// Runs inside the caller's `BEGIN IMMEDIATE` transaction. Expired rows
/// observed here are marked ended and receive an append-only `expire`
/// history record — the audited form of "expired exemptions count
/// again". Rows whose provider is no longer authorized are skipped but
/// left untouched: re-listing the provider restores their effect.
fn load_capacity_exemption_index_in_tx(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
) -> Result<CapacityExemptionIndex> {
Self::load_capacity_exemption_index(conn, policy, true)
}
/// Read-only exemption index for observability surfaces (`br stats`,
/// `br coordination status`): expired exemptions stop counting exactly
/// like enforcement, but the audited lazy-expire records stay pending
/// for the next committed enforcement observation — a read command must
/// not write.
fn load_capacity_exemption_index_read_only(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
) -> Result<CapacityExemptionIndex> {
Self::load_capacity_exemption_index(conn, policy, false)
}
fn load_capacity_exemption_index(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
record_lazy_expirations: bool,
) -> Result<CapacityExemptionIndex> {
if !policy.exemptions.is_enabled()
|| !crate::storage::schema::table_exists(conn, "capacity_exemptions")
{
return Ok(CapacityExemptionIndex::default());
}
let rows = conn.query(
"SELECT issue_id, capacity_kind, capacity_name, provider, expires_at
FROM capacity_exemptions WHERE ended_at IS NULL",
)?;
let now = Utc::now();
let mut index = CapacityExemptionIndex::default();
let mut expired: Vec<(String, String, String, String, String)> = Vec::new();
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(kind) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let Some(name) = row.get(2).and_then(SqliteValue::as_text) else {
continue;
};
let provider = row.get(3).and_then(SqliteValue::as_text).unwrap_or("");
let expires_at = row.get(4).and_then(SqliteValue::as_text);
// An unparseable expiry is treated as already expired: counting
// again is the fail-safe direction for a corrupt record.
let has_expired = expires_at.is_some_and(|raw| {
chrono::DateTime::parse_from_rfc3339(raw)
.map_or(true, |dt| dt.with_timezone(&Utc) <= now)
});
if has_expired {
expired.push((
issue_id.to_string(),
kind.to_string(),
name.to_string(),
provider.to_string(),
expires_at.unwrap_or_default().to_string(),
));
continue;
}
if !policy.exemptions.authorizes(provider) {
continue;
}
index
.by_capacity
.entry((kind.to_string(), name.to_string()))
.or_default()
.insert(issue_id.to_string());
}
if !record_lazy_expirations {
expired.clear();
}
for (issue_id, kind, name, provider, expires_at) in expired {
conn.execute_with_params(
"UPDATE capacity_exemptions
SET ended_at = CURRENT_TIMESTAMP, ended_action = 'expired', ended_by = 'system'
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?
AND ended_at IS NULL",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'expire', ?, ?, 'system', ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(kind.as_str()),
SqliteValue::from(name.as_str()),
SqliteValue::from(provider.as_str()),
SqliteValue::from("expiration observed during capacity enforcement"),
if expires_at.is_empty() {
SqliteValue::Null
} else {
SqliteValue::from(expires_at.as_str())
},
],
)?;
}
let exempt_ids: HashSet<String> = index.by_capacity.values().flatten().cloned().collect();
for id in &exempt_ids {
let rows = conn.query_with_params(
"SELECT status FROM issues WHERE id = ?",
&[SqliteValue::from(id.as_str())],
)?;
if let Some(status) = rows
.first()
.and_then(|row| row.get(0))
.and_then(SqliteValue::as_text)
{
index
.status_of
.insert(id.clone(), status.trim().to_lowercase());
}
}
Ok(index)
}
/// End every active exemption whose applicable status set the issue is
/// leaving in this transition (GitHub #384: "Leaving the applicable
/// status ends the exemption"). A `status`-kind exemption's applicable
/// set is its named status; a `group`-kind exemption's is the group's
/// configured status list. Runs inside the mutation transaction so the
/// ending commits atomically with the departure itself.
fn end_departed_capacity_exemptions_in_tx(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
issue_id: &str,
from: &str,
to: &str,
actor: &str,
) -> Result<()> {
let from_canonical = from.trim().to_lowercase();
let to_canonical = to.trim().to_lowercase();
if from_canonical == to_canonical || from_canonical.is_empty() {
return Ok(());
}
if !crate::storage::schema::table_exists(conn, "capacity_exemptions") {
return Ok(());
}
let rows = conn.query_with_params(
"SELECT capacity_kind, capacity_name, provider, expires_at
FROM capacity_exemptions WHERE issue_id = ? AND ended_at IS NULL",
&[SqliteValue::from(issue_id)],
)?;
for row in &rows {
let Some(kind) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(name) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let provider = row.get(2).and_then(SqliteValue::as_text).unwrap_or("");
let expires_at = row.get(3).and_then(SqliteValue::as_text);
let departed = match kind {
"status" => name == from_canonical && name != to_canonical,
"group" => Self::capacity_group(policy, name).is_some_and(|(_, group)| {
let contains = |candidate: &str| {
group
.statuses
.iter()
.any(|status| status.trim().eq_ignore_ascii_case(candidate))
};
contains(&from_canonical) && !contains(&to_canonical)
}),
_ => false,
};
if !departed {
continue;
}
conn.execute_with_params(
"UPDATE capacity_exemptions
SET ended_at = CURRENT_TIMESTAMP, ended_action = 'left_status', ended_by = ?
WHERE issue_id = ? AND capacity_kind = ? AND capacity_name = ?
AND ended_at IS NULL",
&[
SqliteValue::from(actor),
SqliteValue::from(issue_id),
SqliteValue::from(kind),
SqliteValue::from(name),
],
)?;
conn.execute_with_params(
"INSERT INTO capacity_exemption_history (
issue_id, capacity_kind, capacity_name, action, provider,
reason, actor, expires_at, recorded_at
) VALUES (?, ?, ?, 'left_status', ?, ?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(kind),
SqliteValue::from(name),
SqliteValue::from(provider),
SqliteValue::from(format!(
"issue left the applicable status set ({from_canonical} -> {to_canonical})"
)),
SqliteValue::from(actor),
expires_at.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
}
Ok(())
}
/// Stage Tier 1 attribution for the next mutation (issue #312, Layer 3
/// capture-only). The values are stamped onto every audit event produced by
/// the immediately following `create`/`update`/status-mutating call, then
/// cleared. Pass an empty [`EventAttribution`] (or never call this) to record
/// no attribution. This is a recorded audit trail only — it is never used to
/// gate, reject, or alter any transition.
pub fn set_pending_event_attribution(&mut self, attribution: EventAttribution) {
self.pending_event_attribution = if attribution.is_empty() {
None
} else {
Some(attribution)
};
}
/// Install the already-validated repository workflow-capacity policy used
/// by subsequent creates and status changes.
pub fn set_workflow_capacity_policy(&mut self, policy: crate::close_policy::CapacityPolicy) {
self.workflow_capacity_policy = policy;
}
/// Install the full, already-validated workflow policy. Capacity is cloned
/// into its dedicated hot-path field while transition gates/required fields
/// remain available to transaction-time preflight.
pub fn set_workflow_policy(&mut self, policy: crate::close_policy::Workflow) {
self.workflow_capacity_policy = policy.capacity.clone();
self.workflow_transition_policy = policy;
}
/// Snapshot the installed full workflow policy across JSONL recovery.
#[must_use]
pub(crate) fn workflow_policy(&self) -> crate::close_policy::Workflow {
self.workflow_transition_policy.clone()
}
/// Consume advisory capacity evidence from the most recently committed
/// mutation. The vector follows deterministic policy order and is empty
/// when no soft threshold was reached.
pub fn take_capacity_warnings(&mut self) -> Vec<crate::close_policy::WorkflowCapacityWarning> {
std::mem::take(&mut self.last_capacity_warnings)
}
fn count_capacity_statuses_in_tx(
conn: &Connection,
counts: &mut HashMap<String, u32>,
statuses: &[String],
) -> Result<u32> {
let mut seen = HashSet::new();
let mut total = 0_u32;
for status in statuses {
let canonical = status.trim().to_lowercase();
if canonical.is_empty() || !seen.insert(canonical.clone()) {
continue;
}
let count = if let Some(count) = counts.get(&canonical) {
*count
} else {
let row = conn.query_row_with_params(
"SELECT COUNT(*) FROM issues WHERE status = ?",
&[SqliteValue::from(canonical.as_str())],
)?;
let raw = row
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or_default();
let count = u32::try_from(raw).map_err(|_| {
BeadsError::internal(format!(
"invalid workflow capacity count {raw} for status '{canonical}'"
))
})?;
counts.insert(canonical, count);
count
};
total = total
.checked_add(count)
.ok_or_else(|| BeadsError::internal("workflow capacity count overflowed u32"))?;
}
Ok(total)
}
fn capacity_group<'a>(
policy: &'a crate::close_policy::CapacityPolicy,
name: &str,
) -> Option<(&'a str, &'a crate::close_policy::CapacityGroup)> {
policy
.groups
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(candidate, group)| (candidate.as_str(), group))
}
fn capacity_violation(evidence: CapacityViolationEvidence<'_>) -> BeadsError {
let transition = evidence.transition;
BeadsError::WorkflowCapacityExceeded {
violation: Box::new(crate::close_policy::WorkflowCapacityViolation {
issue_id: transition.issue_id.to_string(),
from_status: transition.from.map(ToString::to_string),
to_status: transition.to.to_string(),
capacity_kind: evidence.kind.to_string(),
capacity_name: evidence.name.to_string(),
scope: evidence.scope.to_string(),
scope_key: evidence.scope_key,
counting_mode: evidence.counting_mode.to_string(),
aggregate_parents_excluded: evidence.aggregate_parents_excluded,
exempt: evidence.exempt,
current: evidence.current,
prospective: evidence.prospective,
soft_limit: evidence.soft_limit,
hard_limit: evidence.hard_limit,
policy_path: evidence.policy_path,
}),
}
}
fn capacity_warning(
evidence: CapacityWarningEvidence<'_>,
) -> crate::close_policy::WorkflowCapacityWarning {
let transition = evidence.transition;
crate::close_policy::WorkflowCapacityWarning {
issue_id: transition.issue_id.to_string(),
from_status: transition.from.map(ToString::to_string),
to_status: transition.to.to_string(),
capacity_kind: evidence.kind.to_string(),
capacity_name: evidence.name.to_string(),
scope: evidence.scope.to_string(),
scope_key: evidence.scope_key,
counting_mode: evidence.counting_mode.to_string(),
aggregate_parents_excluded: evidence.aggregate_parents_excluded,
exempt: evidence.exempt,
current: evidence.current,
prospective: evidence.prospective,
soft_limit: evidence.soft_limit,
hard_limit: evidence.hard_limit,
policy_path: evidence.policy_path,
}
}
fn admission_transition_matches(
rule: &crate::close_policy::CapacityAdmissionRule,
transition: CapacityTransition<'_>,
) -> bool {
let source_matches = transition.from.is_some_and(|source| {
rule.transitions
.from
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(source))
});
source_matches
&& rule
.transitions
.to
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(transition.to))
}
/// Evaluate every repository-level capacity affected by a status change.
///
/// This function must only be called while the caller holds the same
/// `BEGIN IMMEDIATE` transaction that will perform the mutation. Counts
/// therefore observe all prior commits and cannot race another writer for
/// the last slot. A single transition is exactly a one-element batch, so
/// this delegates to the batch evaluator; `issue_type` carries the type of
/// an issue being created (or retyped) in the same mutation so weighted
/// counting can resolve its weight before the row exists.
#[allow(clippy::too_many_arguments)]
pub(crate) fn enforce_workflow_capacity_in_tx(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
issue_id: &str,
from: Option<&str>,
to: &str,
issue_type: Option<&str>,
assignee: CapacityTransitionAssignee<'_>,
acting: &CapacityActingContext,
) -> Result<Vec<crate::close_policy::WorkflowCapacityWarning>> {
if !policy.is_active() || from.is_some_and(|status| status.eq_ignore_ascii_case(to)) {
return Ok(Vec::new());
}
let transitions = [CapacityBatchTransition {
issue_id: issue_id.to_string(),
from: from.map(ToString::to_string),
to: to.to_string(),
issue_type: issue_type.map(ToString::to_string),
current_assignee: assignee.current.map(ToString::to_string),
prospective_assignee: assignee.prospective.map(ToString::to_string),
}];
Self::evaluate_workflow_capacity_batch_in_tx(conn, policy, &transitions, acting)
}
fn transition_enters_capacity(
transition: &CapacityBatchTransition,
statuses: &[String],
) -> bool {
let contains = |candidate: &str| {
statuses
.iter()
.any(|status| status.eq_ignore_ascii_case(candidate))
};
!transition.from.as_deref().is_some_and(contains) && contains(&transition.to)
}
fn transition_drains_capacity(
transition: &CapacityBatchTransition,
statuses: &[String],
) -> bool {
let contains = |candidate: &str| {
statuses
.iter()
.any(|status| status.eq_ignore_ascii_case(candidate))
};
transition.from.as_deref().is_some_and(contains) && !contains(&transition.to)
}
fn batch_prospective_capacity_count(
current: u32,
statuses: &[String],
transitions: &[CapacityBatchTransition],
) -> Result<u32> {
let mut prospective = i64::from(current);
for transition in transitions {
if Self::transition_enters_capacity(transition, statuses) {
prospective += 1;
} else if Self::transition_drains_capacity(transition, statuses) {
prospective -= 1;
}
}
u32::try_from(prospective).map_err(|_| {
BeadsError::internal(format!(
"invalid prospective workflow capacity count {prospective}"
))
})
}
fn batch_transition_ref(transition: &CapacityBatchTransition) -> CapacityTransition<'_> {
CapacityTransition {
issue_id: &transition.issue_id,
from: transition.from.as_deref(),
to: &transition.to,
}
}
/// The transition a capacity change is attributed to: the first one that
/// enters the capacity, or — when hierarchy counting raised the count
/// without any direct entry — the first transition in the batch, so a
/// real increase is never silently unenforced.
fn blamed_capacity_transition<'a>(
engine: &CapacityCountEngine<'a>,
statuses: &[String],
) -> Option<&'a CapacityBatchTransition> {
engine
.transitions()
.iter()
.find(|transition| Self::transition_enters_capacity(transition, statuses))
.or_else(|| engine.transitions().first())
}
fn evaluate_capacity_status_limits_in_tx(
policy: &crate::close_policy::CapacityPolicy,
engine: &mut CapacityCountEngine<'_>,
warnings: &mut Vec<crate::close_policy::WorkflowCapacityWarning>,
) -> Result<()> {
for (status, limit) in &policy.statuses {
let members = [status.clone()];
let pair = engine.counts("status", status, &members)?;
// Under `all`, a rising count always has an entering transition.
// Hierarchy counting can raise a capacity without one — closing a
// child shared by two active parents makes both parents start
// counting — so fall back to the first transition in the batch
// rather than letting the increase escape enforcement.
let entering = Self::blamed_capacity_transition(engine, &members);
if let (Some(hard_limit), Some(transition)) = (limit.hard, entering)
&& pair.prospective > pair.current
&& pair.prospective > hard_limit
{
return Err(Self::capacity_violation(CapacityViolationEvidence {
transition: Self::batch_transition_ref(transition),
kind: "status",
name: status,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit: limit.soft,
hard_limit,
policy_path: format!("workflow.capacity.statuses.{status}"),
}));
}
if let (Some(soft_limit), Some(transition)) = (limit.soft, entering)
&& pair.prospective > pair.current
&& pair.prospective >= soft_limit
{
warnings.push(Self::capacity_warning(CapacityWarningEvidence {
transition: Self::batch_transition_ref(transition),
kind: "status",
name: status,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit,
hard_limit: limit.hard,
policy_path: format!("workflow.capacity.statuses.{status}"),
}));
}
}
Ok(())
}
fn evaluate_capacity_group_limits_in_tx(
policy: &crate::close_policy::CapacityPolicy,
engine: &mut CapacityCountEngine<'_>,
warnings: &mut Vec<crate::close_policy::WorkflowCapacityWarning>,
) -> Result<()> {
for (name, group) in &policy.groups {
let pair = engine.counts("group", name, &group.statuses)?;
let entering = Self::blamed_capacity_transition(engine, &group.statuses);
if let (Some(hard_limit), Some(transition)) = (group.hard, entering)
&& pair.prospective > pair.current
&& pair.prospective > hard_limit
{
return Err(Self::capacity_violation(CapacityViolationEvidence {
transition: Self::batch_transition_ref(transition),
kind: "group",
name,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit: group.soft,
hard_limit,
policy_path: format!("workflow.capacity.groups.{name}"),
}));
}
if let (Some(soft_limit), Some(transition)) = (group.soft, entering)
&& pair.prospective > pair.current
&& pair.prospective >= soft_limit
{
warnings.push(Self::capacity_warning(CapacityWarningEvidence {
transition: Self::batch_transition_ref(transition),
kind: "group",
name,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit,
hard_limit: group.hard,
policy_path: format!("workflow.capacity.groups.{name}"),
}));
}
}
Ok(())
}
fn enforce_capacity_admission_rules_in_tx(
policy: &crate::close_policy::CapacityPolicy,
engine: &mut CapacityCountEngine<'_>,
) -> Result<()> {
for rule in &policy.admission {
let matching = engine
.transitions()
.iter()
.filter(|transition| {
Self::admission_transition_matches(rule, Self::batch_transition_ref(transition))
})
.collect::<Vec<_>>();
if matching.is_empty() {
continue;
}
for (status, threshold) in &rule.require_below.statuses {
let members = [status.clone()];
let pair = engine.counts("status", status, &members)?;
let blocked_transition = matching
.iter()
.find(|transition| !Self::transition_drains_capacity(transition, &members));
if pair.prospective >= *threshold
&& let Some(transition) = blocked_transition
{
return Err(Self::capacity_violation(CapacityViolationEvidence {
transition: Self::batch_transition_ref(transition),
kind: "admission_status",
name: status,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit: None,
hard_limit: *threshold,
policy_path: format!(
"workflow.capacity.admission.{}.require_below.statuses.{status}",
rule.name
),
}));
}
}
for (requested_name, threshold) in &rule.require_below.groups {
let Some((canonical_name, group)) = Self::capacity_group(policy, requested_name)
else {
return Err(BeadsError::internal(format!(
"validated workflow capacity group '{requested_name}' disappeared"
)));
};
let pair = engine.counts("group", canonical_name, &group.statuses)?;
let blocked_transition = matching.iter().find(|transition| {
!Self::transition_drains_capacity(transition, &group.statuses)
});
if pair.prospective >= *threshold
&& let Some(transition) = blocked_transition
{
return Err(Self::capacity_violation(CapacityViolationEvidence {
transition: Self::batch_transition_ref(transition),
kind: "admission_group",
name: canonical_name,
scope: "repository",
scope_key: None,
counting_mode: engine.mode_str(),
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
current: pair.current,
prospective: pair.prospective,
soft_limit: None,
hard_limit: *threshold,
policy_path: format!(
"workflow.capacity.admission.{}.require_below.groups.{canonical_name}",
rule.name
),
}));
}
}
}
Ok(())
}
/// Preflight a complete routed batch against its final prospective state.
///
/// Evaluating the final state (rather than each item in input order) is
/// essential for capacity-neutral swaps: a drain and an admission in the
/// same batch must not fail merely because the admitting ID appeared first.
/// This function is called inside the exact `BEGIN IMMEDIATE` transaction
/// that subsequently applies every update.
fn evaluate_workflow_capacity_batch_in_tx(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
transitions: &[CapacityBatchTransition],
acting: &CapacityActingContext,
) -> Result<Vec<crate::close_policy::WorkflowCapacityWarning>> {
if !policy.is_active() || transitions.is_empty() {
return Ok(Vec::new());
}
let exemptions = Self::load_capacity_exemption_index_in_tx(conn, policy)?;
let mut engine = CapacityCountEngine::new(conn, &policy.counting, transitions, &exemptions);
let mut warnings = Vec::new();
Self::evaluate_capacity_status_limits_in_tx(policy, &mut engine, &mut warnings)?;
Self::evaluate_capacity_group_limits_in_tx(policy, &mut engine, &mut warnings)?;
Self::enforce_capacity_admission_rules_in_tx(policy, &mut engine)?;
Self::evaluate_capacity_scope_limits_in_tx(
conn,
policy,
transitions,
&exemptions,
acting,
&mut warnings,
)?;
Ok(warnings)
}
/// Evaluate every configured capacity scope (GitHub #384 phase 5).
///
/// Scoped counting is plain per-issue occupancy within one partition:
/// hierarchy-aware counting modes and admission rules remain repository
/// features. Each scope's limits compose with the repository limits —
/// a transition must satisfy all of them. Only partitions whose count
/// would INCREASE are checked, so departures and cross-partition
/// handoffs can always proceed.
#[allow(clippy::too_many_lines)]
fn evaluate_capacity_scope_limits_in_tx(
conn: &Connection,
policy: &crate::close_policy::CapacityPolicy,
transitions: &[CapacityBatchTransition],
exemptions: &CapacityExemptionIndex,
acting: &CapacityActingContext,
warnings: &mut Vec<crate::close_policy::WorkflowCapacityWarning>,
) -> Result<()> {
use crate::close_policy::CapacityScopeKind;
if policy.scopes.is_empty() {
return Ok(());
}
// Lazily built shared inputs.
let mut occupancy: Option<HashMap<String, CapacityOccupancyRow>> = None;
let mut subtree: Option<CapacitySubtreeIndex> = None;
for (scope_name, scope_policy) in &policy.scopes {
let Some(kind) = CapacityScopeKind::parse(scope_name) else {
return Err(BeadsError::internal(format!(
"validated workflow capacity scope '{scope_name}' is unrecognized"
)));
};
if !scope_policy.is_active() {
continue;
}
let keying: CapacityScopeKeying = match kind {
CapacityScopeKind::Repository => CapacityScopeKeying::Repository,
CapacityScopeKind::Actor => match &acting.actor {
Some(actor) => CapacityScopeKeying::Acting(actor.clone()),
None => continue,
},
CapacityScopeKind::Harness => match &acting.harness {
Some(harness) => CapacityScopeKeying::Acting(harness.clone()),
None => continue,
},
CapacityScopeKind::Session => match &acting.session {
Some(session) => CapacityScopeKeying::Acting(session.clone()),
None => continue,
},
CapacityScopeKind::Assignee => CapacityScopeKeying::Assignee,
CapacityScopeKind::Subtree => CapacityScopeKeying::Subtree,
};
if matches!(
kind,
CapacityScopeKind::Harness | CapacityScopeKind::Session
) && occupancy.is_none()
{
occupancy = Some(Self::load_capacity_occupancy_for_batch_in_tx(
conn,
transitions,
)?);
}
if matches!(kind, CapacityScopeKind::Actor) && occupancy.is_none() {
occupancy = Some(Self::load_capacity_occupancy_for_batch_in_tx(
conn,
transitions,
)?);
}
if matches!(kind, CapacityScopeKind::Subtree) && subtree.is_none() {
subtree = Some(CapacitySubtreeIndex::load(conn)?);
}
for (status, limit) in &scope_policy.statuses {
let members = [status.clone()];
Self::enforce_one_scoped_capacity_in_tx(
conn,
&ScopedCapacityCheck {
kind_str: "status",
name: status,
members: &members,
limit: *limit,
scope: kind,
keying: &keying,
policy_path: format!(
"workflow.capacity.scopes.{}.statuses.{status}",
kind.as_str()
),
},
transitions,
exemptions,
occupancy.as_ref(),
subtree.as_ref(),
warnings,
)?;
}
for (name, group) in &scope_policy.groups {
Self::enforce_one_scoped_capacity_in_tx(
conn,
&ScopedCapacityCheck {
kind_str: "group",
name,
members: &group.statuses,
limit: group.limit(),
scope: kind,
keying: &keying,
policy_path: format!(
"workflow.capacity.scopes.{}.groups.{name}",
kind.as_str()
),
},
transitions,
exemptions,
occupancy.as_ref(),
subtree.as_ref(),
warnings,
)?;
}
}
Ok(())
}
/// Enforce one scoped capacity across every partition key the batch
/// could increase.
#[allow(clippy::too_many_lines)]
fn enforce_one_scoped_capacity_in_tx(
conn: &Connection,
check: &ScopedCapacityCheck<'_>,
transitions: &[CapacityBatchTransition],
exemptions: &CapacityExemptionIndex,
occupancy: Option<&HashMap<String, CapacityOccupancyRow>>,
subtree: Option<&CapacitySubtreeIndex>,
warnings: &mut Vec<crate::close_policy::WorkflowCapacityWarning>,
) -> Result<()> {
use crate::close_policy::CapacityScopeKind;
let canonical_name = check.name.trim().to_lowercase();
let exempt_ids = exemptions.exempt_ids(check.kind_str, &canonical_name);
let is_exempt = |issue_id: &str| exempt_ids.is_some_and(|ids| ids.contains(issue_id));
// Scope key of a transition, in the entering and draining direction.
let enter_key = |transition: &CapacityBatchTransition| -> Option<String> {
match check.keying {
CapacityScopeKeying::Repository => Some(String::new()),
CapacityScopeKeying::Acting(key) => Some(key.clone()),
CapacityScopeKeying::Assignee => transition
.prospective_assignee
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(ToString::to_string),
CapacityScopeKeying::Subtree => {
subtree.map(|index| index.root_of(&transition.issue_id))
}
}
};
let drain_key = |transition: &CapacityBatchTransition| -> Option<String> {
match check.keying {
CapacityScopeKeying::Repository => Some(String::new()),
CapacityScopeKeying::Acting(_) => occupancy
.and_then(|rows| rows.get(&transition.issue_id))
.and_then(|row| match check.scope {
CapacityScopeKind::Actor => row.actor.clone(),
CapacityScopeKind::Harness => row.harness.clone(),
CapacityScopeKind::Session => row.session.clone(),
_ => None,
}),
CapacityScopeKeying::Assignee => transition
.current_assignee
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(ToString::to_string),
CapacityScopeKeying::Subtree => {
subtree.map(|index| index.root_of(&transition.issue_id))
}
}
};
// Partition keys that gain occupancy from this batch. For the acting
// scopes this is at most the single acting key; for assignee/subtree
// it is the distinct keys of entering transitions.
let mut candidate_keys: Vec<String> = Vec::new();
for transition in transitions {
if is_exempt(&transition.issue_id) {
continue;
}
if Self::transition_enters_capacity(transition, check.members)
&& let Some(key) = enter_key(transition)
&& !candidate_keys.contains(&key)
{
candidate_keys.push(key);
}
}
for key in candidate_keys {
let population = Self::scoped_capacity_population_in_tx(
conn,
check.members,
check.keying,
check.scope,
&key,
subtree,
)?;
let exempt_count = exempt_ids.map_or(0_u32, |ids| {
u32::try_from(population.intersection(ids).count()).unwrap_or(u32::MAX)
});
let current = u32::try_from(population.len())
.unwrap_or(u32::MAX)
.saturating_sub(exempt_count);
let mut prospective = i64::from(current);
let mut blamed: Option<&CapacityBatchTransition> = None;
for transition in transitions {
if is_exempt(&transition.issue_id) {
continue;
}
if Self::transition_enters_capacity(transition, check.members)
&& enter_key(transition).as_deref() == Some(key.as_str())
{
prospective += 1;
if blamed.is_none() {
blamed = Some(transition);
}
} else if Self::transition_drains_capacity(transition, check.members)
&& drain_key(transition).as_deref() == Some(key.as_str())
&& population.contains(&transition.issue_id)
{
prospective -= 1;
}
}
let prospective = u32::try_from(prospective.max(0)).unwrap_or(u32::MAX);
let Some(transition) = blamed.or_else(|| transitions.first()) else {
continue;
};
let scope_key =
(!matches!(check.keying, CapacityScopeKeying::Repository)).then(|| key.clone());
if let Some(hard_limit) = check.limit.hard
&& prospective > current
&& prospective > hard_limit
{
return Err(Self::capacity_violation(CapacityViolationEvidence {
transition: Self::batch_transition_ref(transition),
kind: check.kind_str,
name: check.name,
scope: check.scope.as_str(),
scope_key,
counting_mode: "all",
aggregate_parents_excluded: None,
exempt: (exempt_count > 0).then_some(exempt_count),
current,
prospective,
soft_limit: check.limit.soft,
hard_limit,
policy_path: check.policy_path.clone(),
}));
}
if let Some(soft_limit) = check.limit.soft
&& prospective > current
&& prospective >= soft_limit
{
warnings.push(Self::capacity_warning(CapacityWarningEvidence {
transition: Self::batch_transition_ref(transition),
kind: check.kind_str,
name: check.name,
scope: check.scope.as_str(),
scope_key,
counting_mode: "all",
aggregate_parents_excluded: None,
exempt: (exempt_count > 0).then_some(exempt_count),
current,
prospective,
soft_limit,
hard_limit: check.limit.hard,
policy_path: check.policy_path.clone(),
}));
}
}
Ok(())
}
/// Issue ids currently occupying `members` within one scope partition.
fn scoped_capacity_population_in_tx(
conn: &Connection,
members: &[String],
keying: &CapacityScopeKeying,
scope: crate::close_policy::CapacityScopeKind,
key: &str,
subtree: Option<&CapacitySubtreeIndex>,
) -> Result<HashSet<String>> {
use crate::close_policy::CapacityScopeKind;
let mut population = HashSet::new();
let mut seen = HashSet::new();
for status in members {
let canonical = status.trim().to_lowercase();
if canonical.is_empty() || !seen.insert(canonical.clone()) {
continue;
}
match keying {
CapacityScopeKeying::Repository => {
let rows = conn.query_with_params(
"SELECT id FROM issues WHERE status = ?",
&[SqliteValue::from(canonical.as_str())],
)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
population.insert(id.to_string());
}
}
}
CapacityScopeKeying::Assignee => {
let rows = conn.query_with_params(
"SELECT id FROM issues WHERE status = ? AND assignee = ?",
&[
SqliteValue::from(canonical.as_str()),
SqliteValue::from(key),
],
)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
population.insert(id.to_string());
}
}
}
CapacityScopeKeying::Acting(_) => {
let column = match scope {
CapacityScopeKind::Actor => "actor",
CapacityScopeKind::Harness => "harness",
CapacityScopeKind::Session => "session",
_ => {
return Err(BeadsError::internal(
"acting capacity scope resolved to a non-acting kind",
));
}
};
let sql = format!(
"SELECT i.id FROM issues i \
JOIN capacity_occupancy o ON o.issue_id = i.id \
WHERE i.status = ? AND o.{column} = ?"
);
let rows = conn.query_with_params(
&sql,
&[
SqliteValue::from(canonical.as_str()),
SqliteValue::from(key),
],
)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
population.insert(id.to_string());
}
}
}
CapacityScopeKeying::Subtree => {
let Some(index) = subtree else {
return Err(BeadsError::internal(
"subtree capacity scope evaluated without a loaded hierarchy",
));
};
let rows = conn.query_with_params(
"SELECT id FROM issues WHERE status = ?",
&[SqliteValue::from(canonical.as_str())],
)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text)
&& index.root_of(id) == key
{
population.insert(id.to_string());
}
}
}
}
}
Ok(population)
}
/// Load the occupancy attribution rows for every issue in the batch, so
/// drains can be keyed to the partition that originally admitted them.
fn load_capacity_occupancy_for_batch_in_tx(
conn: &Connection,
transitions: &[CapacityBatchTransition],
) -> Result<HashMap<String, CapacityOccupancyRow>> {
let mut rows_by_issue = HashMap::new();
for transition in transitions {
let result = conn.query_row_with_params(
"SELECT actor, harness, session FROM capacity_occupancy WHERE issue_id = ?",
&[SqliteValue::from(transition.issue_id.as_str())],
);
match result {
Ok(row) => {
let text = |index: usize| {
row.get(index)
.and_then(SqliteValue::as_text)
.map(ToString::to_string)
};
rows_by_issue.insert(
transition.issue_id.clone(),
CapacityOccupancyRow {
actor: text(0),
harness: text(1),
session: text(2),
},
);
}
Err(FrankenError::QueryReturnedNoRows) => {}
Err(error) => return Err(error.into()),
}
}
Ok(rows_by_issue)
}
/// Observed occupancy of every configured capacity (GitHub #384
/// phase 6). Read-only: reuses the enforcement counting engine with an
/// empty transition batch, honors exemptions and hierarchy counting
/// exactly like admission, and never writes (lazy exemption expiry
/// stays pending for the next enforcement observation). Scoped
/// capacities report one row per occupied partition, deterministic
/// order, capped at [`CAPACITY_SNAPSHOT_PARTITION_LIMIT`] per capacity.
///
/// # Errors
///
/// Returns an error if a database query fails.
#[allow(clippy::too_many_lines)]
pub fn capacity_snapshot(&self) -> Result<Vec<CapacitySnapshotRow>> {
use crate::close_policy::CapacityScopeKind;
let policy = self.workflow_capacity_policy.clone();
if !policy.is_active() {
return Ok(Vec::new());
}
let conn = &self.conn;
let exemptions = Self::load_capacity_exemption_index_read_only(conn, &policy)?;
let transitions: [CapacityBatchTransition; 0] = [];
let mut engine =
CapacityCountEngine::new(conn, &policy.counting, &transitions, &exemptions);
let mut rows = Vec::new();
for (status, limit) in &policy.statuses {
let members = [status.clone()];
let pair = engine.counts("status", status, &members)?;
rows.push(CapacitySnapshotRow {
kind: "status".to_string(),
name: status.clone(),
scope: "repository".to_string(),
scope_key: None,
counted: pair.current,
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
soft: limit.soft,
hard: limit.hard,
counting_mode: engine.mode_str().to_string(),
policy_path: format!("workflow.capacity.statuses.{status}"),
});
}
for (name, group) in &policy.groups {
let pair = engine.counts("group", name, &group.statuses)?;
rows.push(CapacitySnapshotRow {
kind: "group".to_string(),
name: name.clone(),
scope: "repository".to_string(),
scope_key: None,
counted: pair.current,
aggregate_parents_excluded: pair.aggregate_parents_excluded,
exempt: pair.exempt,
soft: group.soft,
hard: group.hard,
counting_mode: engine.mode_str().to_string(),
policy_path: format!("workflow.capacity.groups.{name}"),
});
}
let mut subtree: Option<CapacitySubtreeIndex> = None;
for (scope_name, scope_policy) in &policy.scopes {
let Some(scope_kind) = CapacityScopeKind::parse(scope_name) else {
continue;
};
if matches!(scope_kind, CapacityScopeKind::Subtree) && subtree.is_none() {
subtree = Some(CapacitySubtreeIndex::load(conn)?);
}
let checks = scope_policy
.statuses
.iter()
.map(|(status, limit)| {
(
"status",
status.clone(),
vec![status.clone()],
*limit,
format!(
"workflow.capacity.scopes.{}.statuses.{status}",
scope_kind.as_str()
),
)
})
.chain(scope_policy.groups.iter().map(|(name, group)| {
(
"group",
name.clone(),
group.statuses.clone(),
group.limit(),
format!(
"workflow.capacity.scopes.{}.groups.{name}",
scope_kind.as_str()
),
)
}));
for (kind_str, name, members, limit, policy_path) in checks {
Self::snapshot_scoped_capacity(
conn,
&exemptions,
subtree.as_ref(),
scope_kind,
kind_str,
&name,
&members,
limit,
&policy_path,
&mut rows,
)?;
}
}
Ok(rows)
}
/// Append one snapshot row per occupied partition of a scoped capacity.
#[allow(clippy::too_many_arguments)]
fn snapshot_scoped_capacity(
conn: &Connection,
exemptions: &CapacityExemptionIndex,
subtree: Option<&CapacitySubtreeIndex>,
scope_kind: crate::close_policy::CapacityScopeKind,
kind_str: &'static str,
name: &str,
members: &[String],
limit: crate::close_policy::CapacityLimit,
policy_path: &str,
rows: &mut Vec<CapacitySnapshotRow>,
) -> Result<()> {
use crate::close_policy::CapacityScopeKind;
let mut keys = Self::scoped_partition_keys(conn, members, scope_kind, subtree)?;
keys.sort();
keys.dedup();
keys.truncate(CAPACITY_SNAPSHOT_PARTITION_LIMIT);
let canonical_name = name.trim().to_lowercase();
let exempt_ids = exemptions.exempt_ids(kind_str, &canonical_name);
for key in keys {
let keying = match scope_kind {
CapacityScopeKind::Repository => CapacityScopeKeying::Repository,
CapacityScopeKind::Actor
| CapacityScopeKind::Harness
| CapacityScopeKind::Session => CapacityScopeKeying::Acting(key.clone()),
CapacityScopeKind::Assignee => CapacityScopeKeying::Assignee,
CapacityScopeKind::Subtree => CapacityScopeKeying::Subtree,
};
let population = Self::scoped_capacity_population_in_tx(
conn, members, &keying, scope_kind, &key, subtree,
)?;
let exempt_count = exempt_ids.map_or(0_u32, |ids| {
u32::try_from(population.intersection(ids).count()).unwrap_or(u32::MAX)
});
let counted = u32::try_from(population.len())
.unwrap_or(u32::MAX)
.saturating_sub(exempt_count);
let scope_key =
(!matches!(scope_kind, CapacityScopeKind::Repository)).then(|| key.clone());
rows.push(CapacitySnapshotRow {
kind: kind_str.to_string(),
name: name.to_string(),
scope: scope_kind.as_str().to_string(),
scope_key,
counted,
aggregate_parents_excluded: None,
exempt: (exempt_count > 0).then_some(exempt_count),
soft: limit.soft,
hard: limit.hard,
counting_mode: "all".to_string(),
policy_path: policy_path.to_string(),
});
}
Ok(())
}
/// Distinct occupied partition keys for one scoped capacity.
fn scoped_partition_keys(
conn: &Connection,
members: &[String],
scope_kind: crate::close_policy::CapacityScopeKind,
subtree: Option<&CapacitySubtreeIndex>,
) -> Result<Vec<String>> {
use crate::close_policy::CapacityScopeKind;
let mut keys: HashSet<String> = HashSet::new();
let mut seen = HashSet::new();
for status in members {
let canonical = status.trim().to_lowercase();
if canonical.is_empty() || !seen.insert(canonical.clone()) {
continue;
}
match scope_kind {
CapacityScopeKind::Repository => {
keys.insert(String::new());
}
CapacityScopeKind::Assignee => {
let rows = conn.query_with_params(
"SELECT DISTINCT assignee FROM issues \
WHERE status = ? AND assignee IS NOT NULL AND assignee != ''",
&[SqliteValue::from(canonical.as_str())],
)?;
for row in &rows {
if let Some(key) = row.get(0).and_then(SqliteValue::as_text) {
keys.insert(key.to_string());
}
}
}
CapacityScopeKind::Actor
| CapacityScopeKind::Harness
| CapacityScopeKind::Session => {
let column = match scope_kind {
CapacityScopeKind::Actor => "actor",
CapacityScopeKind::Harness => "harness",
_ => "session",
};
let sql = format!(
"SELECT DISTINCT o.{column} FROM capacity_occupancy o \
JOIN issues i ON i.id = o.issue_id \
WHERE i.status = ? AND o.{column} IS NOT NULL"
);
let rows =
conn.query_with_params(&sql, &[SqliteValue::from(canonical.as_str())])?;
for row in &rows {
if let Some(key) = row.get(0).and_then(SqliteValue::as_text) {
keys.insert(key.to_string());
}
}
}
CapacityScopeKind::Subtree => {
let Some(index) = subtree else {
continue;
};
let rows = conn.query_with_params(
"SELECT id FROM issues WHERE status = ?",
&[SqliteValue::from(canonical.as_str())],
)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
keys.insert(index.root_of(id));
}
}
}
}
}
Ok(keys.into_iter().collect())
}
/// Record who moved an issue into its current status, inside the same
/// write transaction (GitHub #384 phase 5). Delete-then-insert mirrors
/// `replace_dirty_issue_marker`; `ON CONFLICT` upserts are avoided for
/// engine compatibility. Deliberately NOT called by the JSONL import
/// path: import is state replication, not admission.
pub(crate) fn record_capacity_occupancy_in_tx(
conn: &Connection,
issue_id: &str,
actor: &str,
attribution: &EventAttribution,
) -> Result<()> {
conn.execute_with_params(
"DELETE FROM capacity_occupancy WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let normalized_actor = actor.trim();
let optional = |value: &Option<String>| {
value
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from)
};
conn.execute_with_params(
"INSERT INTO capacity_occupancy \
(issue_id, actor, agent_name, harness, session, recorded_at) \
VALUES (?, ?, ?, ?, ?, ?)",
&[
SqliteValue::from(issue_id),
if normalized_actor.is_empty() {
SqliteValue::Null
} else {
SqliteValue::from(normalized_actor)
},
optional(&attribution.agent_name),
optional(&attribution.harness),
optional(&attribution.session),
SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str()),
],
)?;
Ok(())
}
/// Remove and return any staged Tier 1 attribution without consuming it via
/// a mutation (issue #312, Layer 3). Used by the JSONL-recovery path to
/// carry a not-yet-committed staged value across a storage rebuild so the
/// post-recovery retry can still stamp it (F1). Preserves the invariant that
/// pending attribution is consumed by exactly one committing mutation, or
/// transferred/cleared — it never leaks into an unrelated operation.
pub(crate) fn take_pending_event_attribution(&mut self) -> Option<EventAttribution> {
self.pending_event_attribution.take()
}
/// Return the normalized attribution staged for the next mutation.
///
/// Reviewed multi-phase mutations bind this value into their immutable
/// intent before entering the write transaction. Returning the normalized
/// empty value instead of exposing the optional slot keeps receipt evidence
/// independent of the storage implementation's staging representation.
#[must_use]
pub(crate) fn pending_event_attribution_for_review(&self) -> EventAttribution {
self.pending_event_attribution.clone().unwrap_or_default()
}
/// Return the exact workflow-capacity policy installed for the next
/// reviewed mutation.
#[must_use]
pub(crate) fn workflow_capacity_policy_for_review(
&self,
) -> crate::close_policy::CapacityPolicy {
self.workflow_capacity_policy.clone()
}
/// Execute a mutation with the 4-step transaction protocol.
///
/// Retries on all transient BUSY errors (from BEGIN, DML, or COMMIT) with
/// exponential backoff. This is the fix for issue #109 — previously only
/// `BusySnapshot` at COMMIT time was retried, while `Busy` from
/// `BEGIN IMMEDIATE` (lock contention) would propagate immediately,
/// causing concurrent close/update operations to silently lose data.
///
/// # Errors
///
/// Returns an error if any step fails (e.g. database error, logic error).
/// The transaction is rolled back on error.
#[allow(clippy::too_many_lines)]
pub fn mutate<F, R>(&mut self, op: &str, actor: &str, mut f: F) -> Result<R>
where
F: FnMut(&Connection, &mut MutationContext) -> Result<R>,
{
// A warning belongs exclusively to the mutation that produced it.
// Clear before BEGIN so failed/retried operations cannot expose stale
// evidence from an earlier command.
self.last_capacity_warnings.clear();
// Disable FK enforcement before the transaction begins. PRAGMA
// foreign_keys can only be changed outside an active transaction.
// fsqlite can surface false FK violations when its page buffer pool
// is exhausted, even though the referenced issue_id was just
// written/verified in the same transaction (#215). All FK
// invariants (dependencies -> issues, events -> issues,
// dirty_issues -> issues, etc.) are enforced by application logic
// within the mutation closures.
self.conn.execute("PRAGMA foreign_keys = OFF")?;
// Peek (clone) — do NOT take — the per-command attribution staged for
// this mutation. We must not permanently consume the staged value until
// the mutation is known to COMMIT: if `with_write_transaction` returns a
// recoverable `Database(_)` error, `retry_mutation_with_jsonl_recovery`
// re-invokes this `mutate()` after rebuilding the DB from JSONL, and the
// staged slot must still be present so the recovered write records the
// attribution (#312 hardening, F1). Cloning here also means the internal
// BUSY-retry loop inside `with_write_transaction` re-applies the SAME
// value on each attempt and stamps exactly once on the committing run.
//
// Invariant: pending attribution is consumed by exactly one committing
// mutation, or cleared — it never leaks into a later, unrelated
// operation. It is `.take()`n below only after a successful commit.
let pending_attribution = self.pending_event_attribution.clone().unwrap_or_default();
let tx_result: Result<_> = self.with_write_transaction(|storage| {
let mut ctx = MutationContext::new(op, actor);
ctx.attribution = pending_attribution.clone();
let result = f(&storage.conn, &mut ctx)?;
// Write events
if !ctx.events.is_empty() {
let sql = "INSERT INTO events (issue_id, event_type, actor, old_value, new_value, comment, created_at, agent_name, harness, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
for event in &ctx.events {
let params = vec![
SqliteValue::from(event.issue_id.as_str()),
SqliteValue::from(event.event_type.as_str()),
SqliteValue::from(event.actor.as_str()),
event
.old_value
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
event
.new_value
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
event
.comment
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(event.created_at.to_rfc3339()),
event
.agent_name
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
event
.harness
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
event
.model
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
];
storage.conn.execute_with_params(sql, ¶ms)?;
}
}
// Mark dirty
if !ctx.dirty_ids.is_empty() {
let now_str = Utc::now().to_rfc3339();
// Collect IDs into a Vec for chunked processing
let dirty_vec: Vec<_> = ctx.dirty_ids.iter().collect();
for chunk in dirty_vec.chunks(DIRTY_ISSUE_CHUNK_SIZE) {
// Explicit DELETE + INSERT instead of INSERT OR REPLACE because
// fsqlite does not reliably support UNIQUE constraint upserts.
for insert_chunk in chunk.chunks(450) {
// Delete existing entries row-by-row to avoid fsqlite IN-clause bugs
for id in insert_chunk {
storage.conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from(id.as_str())],
)?;
}
// Now insert fresh rows one by one
for id in insert_chunk {
storage.conn.execute_with_params(
"INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?)",
&[
SqliteValue::from(id.as_str()),
SqliteValue::from(now_str.as_str()),
],
)?;
}
}
}
}
let mut blocked_cache_plan = BlockedCacheRefreshPlan::from_context(&ctx);
if blocked_cache_plan.is_some() {
// An Incremental refresh only rewrites its own connected
// component and then declares the WHOLE cache fresh, which is
// sound only if the cache was already consistent. If a prior
// Deferred write left the cache stale (its edge is committed
// but not yet in blocked_issues_cache), an Incremental refresh
// would clear the stale marker while that earlier change is
// still missing — reporting a blocked issue as ready. Upgrade
// to a Full rebuild in that case (Full is always correct), so
// "the next non-deferred write rebuilds the cache" holds.
if matches!(
blocked_cache_plan,
Some(BlockedCacheRefreshPlan::Incremental(_))
) && Self::metadata_equals(
&storage.conn,
BLOCKED_CACHE_STATE_KEY,
BLOCKED_CACHE_STATE_STALE,
)? {
blocked_cache_plan = Some(BlockedCacheRefreshPlan::Full);
}
storage.set_metadata_in_tx(BLOCKED_CACHE_STATE_KEY, BLOCKED_CACHE_STATE_STALE)?;
if let Some(BlockedCacheRefreshPlan::Incremental(ids)) = &blocked_cache_plan {
// Check freshness, update the component and clear stale in
// the SAME write transaction. A second transaction could
// clear an intervening writer's unrelated invalidation.
// Keep the stale marker outside the savepoint so a cache
// failure can preserve the primary mutation and fall back
// to a complete post-commit repair.
storage.conn.execute("SAVEPOINT br_blocked_cache")?;
let refresh = Self::incremental_blocked_cache_update(&storage.conn, ids)
.and_then(|count| {
Self::upsert_metadata_key_in_tx(
&storage.conn,
BLOCKED_CACHE_STATE_KEY,
METADATA_EMPTY_VALUE,
)?;
Ok(count)
});
match refresh {
Ok(refreshed) => {
storage.conn.execute("RELEASE br_blocked_cache")?;
tracing::debug!(operation = op, refreshed, "Refreshed blocked cache inside mutation");
blocked_cache_plan = None;
}
Err(error) => {
// If either boundary fails, abort the outer
// transaction rather than commit partial cache data.
storage.conn.execute("ROLLBACK TO br_blocked_cache")?;
storage.conn.execute("RELEASE br_blocked_cache")?;
tracing::warn!(operation = op, %error, "Incremental cache refresh rolled back; scheduling full repair");
blocked_cache_plan = Some(BlockedCacheRefreshPlan::Full);
}
}
}
}
if ctx.force_flush {
Self::upsert_metadata_key_in_tx(&storage.conn, NEEDS_FLUSH_KEY, "true")?;
}
Ok((result, blocked_cache_plan, ctx.capacity_warnings))
});
// Consume the staged attribution only now that the transaction has
// COMMITTED (#312 hardening, F1). On a recoverable error the slot is
// intentionally left intact so the JSONL-recovery retry can re-stamp it.
if tx_result.is_ok() {
self.pending_event_attribution = None;
}
// Re-enable FK enforcement after the transaction completes
// (regardless of success or failure).
let (result, blocked_cache_plan, capacity_warnings) =
Self::finish_foreign_key_suppressed_result(&self.conn, op, tx_result)?;
self.last_capacity_warnings = capacity_warnings;
match blocked_cache_plan {
Some(BlockedCacheRefreshPlan::Deferred) => {
// Stale marker already set inside the transaction. Reads will
// compute blocked state in-memory until the next non-deferred
// write rebuilds the cache. Skipping the eager second write
// transaction eliminates DB lock contention for dep add/remove.
tracing::debug!(
operation = op,
"Blocked cache refresh deferred; will rebuild lazily on next read"
);
}
Some(ref plan) => {
if let Err(error) = self.refresh_blocked_cache_after_commit(op, plan) {
self.handle_blocked_cache_refresh_error(op, error)?;
}
}
None => {}
}
Ok(result)
}
/// Create a new issue.
///
/// # Errors
///
/// Returns an error if the issue cannot be inserted (e.g. ID collision).
#[allow(clippy::too_many_lines)]
pub fn create_issue(&mut self, issue: &Issue, actor: &str) -> Result<()> {
IssueValidator::validate(issue).map_err(BeadsError::from_validation_errors)?;
validate_issue_comments_for_create(issue)?;
let capacity_policy = self.workflow_capacity_policy.clone();
self.mutate("create_issue", actor, |conn, ctx| {
// Explicit duplicate check since fsqlite does not enforce
// UNIQUE constraints on non-rowid columns.
match conn.query_row_with_params(
"SELECT 1 FROM issues WHERE id = ? LIMIT 1",
&[SqliteValue::from(issue.id.as_str())],
) {
Ok(_) => {
return Err(BeadsError::IdCollision {
id: issue.id.clone(),
});
}
Err(FrankenError::QueryReturnedNoRows) => {}
Err(error) => return Err(error.into()),
}
// Check for external_ref collision
if let Some(ref ext_ref) = issue.external_ref {
let existing_ext = conn.query_with_params(
"SELECT id FROM issues WHERE external_ref = ? LIMIT 1",
&[SqliteValue::from(ext_ref.as_str())],
)?;
if let Some(existing_row) = existing_ext.first() {
let other_id = existing_row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or_default()
.to_string();
return Err(BeadsError::Config(format!(
"External reference '{ext_ref}' already exists on issue {other_id}"
)));
}
}
let acting = CapacityActingContext::new(&ctx.actor, &ctx.attribution);
let capacity_warnings = Self::enforce_workflow_capacity_in_tx(
conn,
&capacity_policy,
&issue.id,
None,
issue.status.as_str(),
Some(issue.issue_type.as_str()),
CapacityTransitionAssignee {
current: None,
prospective: issue.assignee.as_deref(),
},
&acting,
)?;
ctx.capacity_warnings.extend(capacity_warnings);
let status_str = issue.status.as_str();
let issue_type_str = issue.issue_type.as_str();
let created_at_str = issue.created_at.to_rfc3339();
let updated_at_str = issue.updated_at.to_rfc3339();
let closed_at_str = issue.closed_at.map(|dt| dt.to_rfc3339());
let due_at_str = issue.due_at.map(|dt| dt.to_rfc3339());
let defer_until_str = issue.defer_until.map(|dt| dt.to_rfc3339());
let deleted_at_str = issue.deleted_at.map(|dt| dt.to_rfc3339());
let compacted_at_str = issue.compacted_at.map(|dt| dt.to_rfc3339());
let content_hash = issue.compute_content_hash();
conn.execute_with_params(
"INSERT INTO issues (
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, source_repo_path, deleted_at, deleted_by, delete_reason, original_type,
compaction_level, compacted_at, compacted_at_commit, original_size,
sender, ephemeral, pinned, is_template, agent_context
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
&[
SqliteValue::from(issue.id.as_str()),
SqliteValue::from(content_hash.as_str()),
SqliteValue::from(issue.title.as_str()),
SqliteValue::from(issue.description.as_deref().unwrap_or("")),
SqliteValue::from(issue.design.as_deref().unwrap_or("")),
SqliteValue::from(issue.acceptance_criteria.as_deref().unwrap_or("")),
SqliteValue::from(issue.notes.as_deref().unwrap_or("")),
SqliteValue::from(status_str),
SqliteValue::from(issue.priority.0),
SqliteValue::from(issue_type_str),
issue.assignee.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.owner.as_deref().unwrap_or("")),
issue.estimated_minutes.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(created_at_str.as_str()),
SqliteValue::from(issue.created_by.as_deref().unwrap_or("")),
SqliteValue::from(updated_at_str.as_str()),
closed_at_str.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.close_reason.as_deref().unwrap_or("")),
SqliteValue::from(issue.closed_by_session.as_deref().unwrap_or("")),
due_at_str.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
defer_until_str.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
issue.external_ref.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.source_system.as_deref().unwrap_or("")),
SqliteValue::from(issue.source_repo.as_deref().unwrap_or(".")),
issue.source_repo_path.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
deleted_at_str.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.deleted_by.as_deref().unwrap_or("")),
SqliteValue::from(issue.delete_reason.as_deref().unwrap_or("")),
SqliteValue::from(issue.original_type.as_deref().unwrap_or("")),
SqliteValue::from(i64::from(issue.compaction_level.unwrap_or(0))),
compacted_at_str.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
issue.compacted_at_commit.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(i64::from(issue.original_size.unwrap_or(0))),
SqliteValue::from(issue.sender.as_deref().unwrap_or("")),
SqliteValue::from(i64::from(i32::from(issue.ephemeral))),
SqliteValue::from(i64::from(i32::from(issue.pinned))),
SqliteValue::from(i64::from(i32::from(issue.is_template))),
issue.agent_context.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
// GitHub #384 phase 5: creation admits the issue into its
// initial status; record the admitting attribution.
Self::record_capacity_occupancy_in_tx(conn, &issue.id, &ctx.actor, &ctx.attribution)?;
// Update child counter if this is a hierarchical ID
if let Ok(parsed) = parse_id(&issue.id)
&& !parsed.is_root()
&& let Some(parent) = parsed.parent()
&& let Some(&child_num) = parsed.child_path.last()
{
Self::update_child_counter_in_tx(conn, &parent, child_num)?;
}
// Insert Labels
let mut seen_labels = HashSet::new();
for label in &issue.labels {
if !seen_labels.insert(label.as_str()) {
continue;
}
conn.execute_with_params(
"INSERT INTO labels (issue_id, label) VALUES (?, ?)",
&[SqliteValue::from(issue.id.as_str()), SqliteValue::from(label.as_str())],
)?;
ctx.record_event(
EventType::LabelAdded,
&issue.id,
Some(format!("Added label {label}")),
);
}
// Insert Dependencies
let mut seen_deps = HashSet::new();
for dep in &issue.dependencies {
if dep.depends_on_id == issue.id {
return Err(BeadsError::SelfDependency {
id: issue.id.clone(),
});
}
if !seen_deps.insert(dep.depends_on_id.as_str()) {
continue;
}
Self::ensure_dependency_target_exists_in_tx(conn, &dep.depends_on_id)?;
// Check cycle if blocking.
if Self::check_dependency_cycle_for_type(
conn,
&issue.id,
&dep.depends_on_id,
&dep.dep_type,
true,
)? {
return Err(BeadsError::DependencyCycle {
path: format!(
"Adding dependency {} -> {} would create a cycle",
issue.id, dep.depends_on_id
),
});
}
conn.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, ?, ?, ?, ?)",
&[
SqliteValue::from(issue.id.as_str()),
SqliteValue::from(dep.depends_on_id.as_str()),
SqliteValue::from(dep.dep_type.as_str()),
SqliteValue::from(dep.created_at.to_rfc3339()),
SqliteValue::from(dep.created_by.as_deref().unwrap_or(actor)),
],
)?;
ctx.record_event(
EventType::DependencyAdded,
&issue.id,
Some(format!(
"Added dependency on {} ({})",
dep.depends_on_id, dep.dep_type
)),
);
ctx.invalidate_cache_for(&[issue.id.as_str(), dep.depends_on_id.as_str()]);
}
// Insert Comments
for comment in &issue.comments {
conn.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from(issue.id.as_str()),
SqliteValue::from(comment.author.as_str()),
SqliteValue::from(comment.body.as_str()),
SqliteValue::from(comment.created_at.to_rfc3339()),
],
)?;
ctx.record_event(
EventType::Commented,
&issue.id,
Some(comment.body.clone()),
);
}
ctx.record_event(
EventType::Created,
&issue.id,
Some(format!("Created issue: {}", issue.title)),
);
ctx.mark_dirty(&issue.id);
Ok(())
})
}
/// Iterative BFS cycle detection (replaces recursive CTE).
///
/// Checks whether adding an edge `issue_id -> depends_on_id` would create
/// a cycle. This works by starting from `depends_on_id` and walking its
/// transitive forward dependencies; if any reachable node equals `issue_id`,
/// a cycle would be formed.
///
/// Uses lazy per-node BFS: instead of bulk-loading the entire dependency
/// graph into memory, each BFS step queries only the immediate neighbors
/// of the current frontier node. On sparse graphs this visits a tiny
/// fraction of total edges, giving dramatic speedups for `dep add` on
/// large repositories (e.g. 813-issue graphs).
///
/// Two kinds of edges are followed in the blocker graph:
/// 1. Standard deps (`issue_id -> depends_on_id`), filtered by type.
/// 2. Parent-child edges reversed (`depends_on_id -> issue_id`), since a
/// parent finishing requires its children to finish first.
///
/// A depth cap prevents pathological traversal on corrupted graphs.
fn check_cycle(
conn: &Connection,
issue_id: &str,
depends_on_id: &str,
blocking_only: bool,
) -> Result<bool> {
// Build the per-node neighbor query. We UNION two directions:
// (a) standard deps: given node as issue_id, follow to depends_on_id
// (b) parent-child reversed: given node as depends_on_id (parent),
// follow to issue_id (child) -- because parent is blocked by child
let neighbor_sql = if blocking_only {
"SELECT depends_on_id FROM dependencies \
WHERE issue_id = ? AND type IN ('blocks', 'conditional-blocks', 'waits-for') \
UNION \
SELECT issue_id FROM dependencies \
WHERE depends_on_id = ? AND type = 'parent-child'"
.to_string()
} else {
"SELECT depends_on_id FROM dependencies \
WHERE issue_id = ? AND type != 'parent-child' \
UNION \
SELECT issue_id FROM dependencies \
WHERE depends_on_id = ? AND type = 'parent-child'"
.to_string()
};
let stmt = conn.prepare(&neighbor_sql)?;
let mut visited = HashSet::new();
// Level-synchronous BFS: process all nodes at one depth before moving
// to the next, so we can enforce a depth cap cleanly.
let mut frontier: Vec<String> = vec![depends_on_id.to_string()];
visited.insert(depends_on_id.to_string());
for _depth in 0..DEPENDENCY_TRAVERSAL_MAX_DEPTH {
if frontier.is_empty() {
break;
}
let mut next_frontier = Vec::new();
for node in &frontier {
let rows = stmt.query_with_params(&[
SqliteValue::from(node.as_str()),
SqliteValue::from(node.as_str()),
])?;
for row in &rows {
if let Some(neighbor) = row.get(0).and_then(SqliteValue::as_text) {
if neighbor == issue_id {
return Ok(true); // Cycle detected -- early exit
}
if visited.insert(neighbor.to_string()) {
next_frontier.push(neighbor.to_string());
}
}
}
}
frontier = next_frontier;
}
Ok(false)
}
fn check_parent_child_cycle(
conn: &Connection,
child_id: &str,
parent_id: &str,
blocking_only: bool,
) -> Result<bool> {
// Stored parent-child rows are child -> parent, but the blocker graph
// edge is parent -> child because parents wait for children.
Self::check_cycle(conn, parent_id, child_id, blocking_only)
}
fn check_dependency_cycle_for_type(
conn: &Connection,
issue_id: &str,
depends_on_id: &str,
dep_type: &DependencyType,
blocking_only: bool,
) -> Result<bool> {
if !dep_type.is_blocking() {
return Ok(false);
}
if matches!(dep_type, DependencyType::ParentChild) {
Self::check_parent_child_cycle(conn, issue_id, depends_on_id, blocking_only)
} else {
Self::check_cycle(conn, issue_id, depends_on_id, blocking_only)
}
}
/// Update an issue's fields.
///
/// # Errors
///
/// Returns an error if the issue doesn't exist or the update fails.
pub fn update_issue(&mut self, id: &str, updates: &IssueUpdate, actor: &str) -> Result<Issue> {
let updates = [(id.to_string(), updates.clone())];
self.update_issues_atomically(&updates, actor)?
.pop()
.ok_or_else(|| BeadsError::IssueNotFound { id: id.to_string() })
}
#[allow(clippy::too_many_lines)]
fn enforce_workflow_transition_batch_in_tx(
conn: &Connection,
workflow: &crate::close_policy::Workflow,
updates: &[(String, IssueUpdate)],
) -> Result<()> {
for (id, update) in updates {
let Some(to_status) = update.status.as_ref() else {
if update.transition_comment.is_some()
|| update.workflow_policy_bypass_reason.is_some()
{
return Err(BeadsError::validation(
"status",
format!(
"issue {id}: transition comments and workflow-policy bypasses require a real status transition"
),
));
}
continue;
};
let issue = Self::get_issue_from_conn(conn, id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })?;
if issue.status == *to_status {
if update.transition_comment.is_some()
|| update.workflow_policy_bypass_reason.is_some()
{
return Err(BeadsError::validation(
"status",
format!(
"issue {id}: transition comments and workflow-policy bypasses cannot be attached to a same-status update"
),
));
}
continue;
}
if let Some(reason) = update.workflow_policy_bypass_reason.as_deref() {
if reason.trim().is_empty() {
return Err(BeadsError::validation(
"bypass_reason",
format!("issue {id}: workflow-policy bypass reason must not be empty"),
));
}
continue;
}
let from = issue.status.as_str();
let to = to_status.as_str();
// GitHub #399: enforce `workflow.transitions` at the storage
// chokepoint. `br update` validated the transition in its own CLI
// layer, but `br close` (and the MCP/epic batch writers) reached
// this preflight with only required-field and gate evaluation, so
// a status move the transitions map forbids still committed.
// Validating here — inside the same `BEGIN IMMEDIATE` preflight,
// before any row is touched — makes a batch close all-or-nothing
// and leaves `--bypass-policy` semantics intact because an
// explicit bypass reason already `continue`d above.
workflow.validate_transition(Some(from), to)?;
let prospective_acceptance_criteria = update
.acceptance_criteria
.as_ref()
.map(|value| value.as_deref())
.unwrap_or(issue.acceptance_criteria.as_deref());
let mut violations = crate::close_policy::evaluate_transition_required_fields(
workflow,
id,
Some(from),
to,
prospective_acceptance_criteria,
update.transition_comment.as_deref(),
);
if workflow.gates_enforced() && workflow.gate_rule_for(from, to).is_some() {
let label_rows = conn.query_with_params(
"SELECT label FROM labels WHERE issue_id = ? ORDER BY label",
&[SqliteValue::from(id.as_str())],
)?;
let labels = label_rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect::<Vec<_>>();
let priority = update.priority.map_or(issue.priority.0, |value| value.0);
let status_revision = Self::status_revision_in_tx(conn, id)?;
let results =
Self::get_scoped_gate_results_in_tx(conn, id, from, to, status_revision)?;
let required_gates = workflow.required_gates_for(from, to, &labels, priority);
let mut gate_violations = crate::close_policy::evaluate_gates(
workflow, id, from, to, &labels, priority, &results,
);
for violation in &mut gate_violations {
let Some(gate_id) = violation.gate.strip_prefix("gate_") else {
continue;
};
let Some(spec) = required_gates
.iter()
.find(|spec| spec.id().eq_ignore_ascii_case(gate_id))
else {
continue;
};
let stale_revisions = Self::prior_satisfying_gate_revisions_in_tx(
conn,
id,
&from.to_ascii_lowercase(),
&to.to_ascii_lowercase(),
status_revision,
spec,
)?;
if stale_revisions.is_empty() {
continue;
}
violation.message.push_str(&format!(
" A pass exists only for stale status revision(s) {}; report a fresh result for current revision {status_revision}.",
stale_revisions
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ")
));
if let Some(serde_json::Value::Object(detail)) = violation.detail.as_mut() {
detail.insert(
"reason".to_string(),
serde_json::Value::String("stale_status_revision".to_string()),
);
detail.insert(
"current_status_revision".to_string(),
serde_json::Value::from(status_revision),
);
detail.insert(
"stale_status_revisions".to_string(),
serde_json::json!(stale_revisions),
);
}
}
violations.extend(gate_violations);
}
if !violations.is_empty() {
let summary = if let [single] = violations.as_slice() {
single.message.clone()
} else {
let lines = violations
.iter()
.map(|violation| format!("- {}", violation.message))
.collect::<Vec<_>>()
.join("\n");
format!(
"{} workflow requirement(s) failed:\n{lines}",
violations.len()
)
};
return Err(BeadsError::PolicyViolation {
issue_id: id.clone(),
summary,
violations,
});
}
}
Ok(())
}
/// Update an ordered set of issues in one `BEGIN IMMEDIATE` transaction.
///
/// Capacity is preflighted against the batch's final prospective state and
/// every field mutation, audit event, and dirty marker commits or rolls back
/// as a unit. Duplicate IDs are rejected because applying two updates to the
/// same row would make request-order semantics ambiguous.
///
/// # Errors
///
/// Returns an error without modifying any issue when any ID, validation,
/// claim guard, uniqueness check, or workflow-capacity rule fails.
#[allow(clippy::too_many_lines)]
pub fn update_issues_atomically(
&mut self,
updates: &[(String, IssueUpdate)],
actor: &str,
) -> Result<Vec<Issue>> {
self.last_capacity_warnings.clear();
let mut seen = HashSet::with_capacity(updates.len());
for (id, _) in updates {
if !seen.insert(id.as_str()) {
self.pending_event_attribution = None;
return Err(BeadsError::validation(
"issue_ids",
format!("duplicate issue ID in atomic update batch: {id}"),
));
}
}
if updates.iter().all(|(_, update)| update.is_empty()) {
// This path does not call `mutate()`, so explicitly consume staged
// attribution just like the historical single-update no-op path.
self.pending_event_attribution = None;
} else {
let capacity_policy = self.workflow_capacity_policy.clone();
let workflow_policy = self.workflow_transition_policy.clone();
self.mutate("update_issues_atomically", actor, |conn, ctx| {
let mut transitions = Vec::new();
for (id, update) in updates {
let issue = Self::get_issue_from_conn(conn, id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })?;
if issue.status == Status::Tombstone && !update.is_empty() {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot update tombstone issue: {id}"),
});
}
if let Some(status) = &update.status
&& issue.status != *status
{
transitions.push(CapacityBatchTransition {
issue_id: id.clone(),
from: Some(issue.status.as_str().to_string()),
to: status.as_str().to_string(),
issue_type: update
.issue_type
.as_ref()
.map(|issue_type| issue_type.as_str().to_string()),
current_assignee: issue.assignee.clone(),
prospective_assignee: match &update.assignee {
Some(prospective) => prospective.clone(),
None => issue.assignee.clone(),
},
});
}
}
Self::enforce_workflow_transition_batch_in_tx(conn, &workflow_policy, updates)?;
let acting = CapacityActingContext::new(&ctx.actor, &ctx.attribution);
let capacity_warnings = Self::evaluate_workflow_capacity_batch_in_tx(
conn,
&capacity_policy,
&transitions,
&acting,
)?;
ctx.capacity_warnings.extend(capacity_warnings);
for (id, update) in updates {
if !update.is_empty() {
Self::update_issue_in_tx(conn, ctx, id, update, actor)?;
}
}
// Status updates affect each blocker's direct dependents as
// well as its own component. Collect them for the whole batch
// inside this transaction instead of querying once per issue.
// Include repeated statuses: cache invalidation also applies
// when a requested status equals the issue's current status.
let status_ids: Vec<_> = updates
.iter()
.filter(|(_, update)| update.status.is_some() && !update.skip_cache_rebuild)
.map(|(id, _)| SqliteValue::from(id.as_str()))
.collect();
for chunk in status_ids.chunks(400) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let dependents = conn.query_with_params(
&format!(
"SELECT issue_id FROM dependencies WHERE depends_on_id IN ({placeholders})
AND type IN ('blocks', 'conditional-blocks', 'waits-for')"
),
chunk,
)?;
for row in &dependents {
if let Some(dependent) = row.get(0).and_then(SqliteValue::as_text) {
ctx.invalidate_cache_for(&[dependent]);
}
}
}
// GitHub #384 phase 5: record who moved each issue into its
// new status so scoped capacities can key future admissions.
for transition in &transitions {
Self::record_capacity_occupancy_in_tx(
conn,
&transition.issue_id,
&ctx.actor,
&ctx.attribution,
)?;
}
// GitHub #384 phase 4: leaving the applicable status ends an
// issue's capacity exemption, atomically with the departure.
for transition in &transitions {
if let Some(from) = transition.from.as_deref() {
Self::end_departed_capacity_exemptions_in_tx(
conn,
&capacity_policy,
&transition.issue_id,
from,
&transition.to,
actor,
)?;
}
}
Ok(())
})?;
}
updates
.iter()
.map(|(id, _)| {
self.get_issue(id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })
})
.collect()
}
#[allow(clippy::too_many_lines)]
fn update_issue_in_tx(
conn: &Connection,
ctx: &mut MutationContext,
id: &str,
updates: &IssueUpdate,
actor: &str,
) -> Result<()> {
let mut issue = Self::get_issue_from_conn(conn, id)?.ok_or_else(|| {
// Issue #245: if `get_issue` (read path) can find the row but
// `get_issue_from_conn` inside a write transaction cannot, the
// database is likely corrupt (B-tree or index malformation).
// Surface a more helpful error than bare ISSUE_NOT_FOUND.
tracing::warn!(
id = %id,
"update_issue: row not found inside write transaction \
(possible DB corruption — run `br doctor --repair`)"
);
BeadsError::IssueNotFound { id: id.to_string() }
})?;
if issue.status == Status::Tombstone {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot update tombstone issue: {id}"),
});
}
// Atomic claim guard: check assignee INSIDE the CONCURRENT transaction
// to prevent TOCTOU races where two agents both see "unassigned".
if updates.expect_unassigned {
let current_assignee = match conn.query_row_with_params(
"SELECT assignee FROM issues WHERE id = ?",
&[SqliteValue::from(id)],
) {
Ok(row) => row.get(0).and_then(SqliteValue::as_text).map(String::from),
Err(FrankenError::QueryReturnedNoRows) => None,
Err(error) => return Err(error.into()),
};
let trimmed = current_assignee
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let claim_actor = updates.claim_actor.as_deref().unwrap_or("");
match trimmed {
None => { /* unassigned, proceed with claim */ }
Some(current) if !updates.claim_exclusive && current == claim_actor => {
/* same actor re-claim, idempotent */
}
Some(current) => {
return Err(BeadsError::validation(
"claim",
format!("issue {id} already assigned to {current}"),
));
}
}
}
let mut set_clauses: Vec<String> = vec![];
let mut params: Vec<SqliteValue> = vec![];
// Helper to add update
let mut add_update = |field: &str, val: SqliteValue| {
set_clauses.push(format!("{field} = ?"));
params.push(val);
};
// Title
if let Some(ref title) = updates.title {
let old_title = issue.title.clone();
issue.title.clone_from(title);
add_update("title", SqliteValue::from(title.as_str()));
ctx.record_field_change(
EventType::Updated,
id,
Some(old_title),
Some(title.clone()),
Some("Title changed".to_string()),
);
}
// Simple text fields - use empty string instead of NULL for bd compatibility
if let Some(ref val) = updates.description {
issue.description.clone_from(val);
add_update(
"description",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
if let Some(ref val) = updates.design {
issue.design.clone_from(val);
add_update("design", SqliteValue::from(val.as_deref().unwrap_or("")));
}
if let Some(ref val) = updates.acceptance_criteria {
issue.acceptance_criteria.clone_from(val);
add_update(
"acceptance_criteria",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
if let Some(ref val) = updates.notes {
issue.notes.clone_from(val);
add_update("notes", SqliteValue::from(val.as_deref().unwrap_or("")));
}
// Status
if let Some(ref status) = updates.status {
let old_status_obj = issue.status.clone();
let old_status = old_status_obj.as_str().to_string();
let was_terminal = old_status_obj.is_terminal();
issue.status.clone_from(status);
add_update("status", SqliteValue::from(status.as_str()));
if status.as_str() != old_status {
ctx.record_field_change(
EventType::StatusChanged,
id,
Some(old_status),
Some(status.as_str().to_string()),
None,
);
if let Some(comment) = updates.transition_comment.as_deref() {
let comment = comment.trim();
validate_new_comment(id, actor, comment)?;
insert_comment_row(conn, id, actor, comment)?;
ctx.record_event(EventType::Commented, id, Some(comment.to_string()));
}
if let Some(reason) = updates.workflow_policy_bypass_reason.as_deref() {
ctx.record_event(
EventType::Custom("workflow_policy_bypassed".to_string()),
id,
Some(reason.trim().to_string()),
);
}
}
// Record Closed event if status is now Closed and wasn't before
if *status == Status::Closed {
if !was_terminal {
let reason = updates.close_reason.as_ref().and_then(Clone::clone);
ctx.record_event(EventType::Closed, id, reason);
}
// Auto-set closed_at if not provided
if updates.closed_at.is_none() && issue.closed_at.is_none() {
let now = Utc::now();
issue.closed_at = Some(now);
add_update("closed_at", SqliteValue::from(now.to_rfc3339()));
}
if issue.deleted_at.is_some() {
issue.deleted_at = None;
issue.deleted_by = None;
issue.delete_reason = None;
add_update("deleted_at", SqliteValue::Null);
add_update("deleted_by", SqliteValue::Null);
add_update("delete_reason", SqliteValue::Null);
}
} else if *status == Status::Tombstone {
let reason = updates.close_reason.as_ref().and_then(Clone::clone);
if !was_terminal {
ctx.record_event(EventType::Deleted, id, reason.clone());
}
let now = Utc::now();
issue.deleted_at = Some(now);
issue.deleted_by = Some(actor.to_string());
issue.delete_reason.clone_from(&reason);
add_update("deleted_at", SqliteValue::from(now.to_rfc3339()));
add_update("deleted_by", SqliteValue::from(actor));
// Always update delete_reason if we are setting to Tombstone,
// using close_reason as fallback if provided.
add_update(
"delete_reason",
SqliteValue::from(reason.as_deref().unwrap_or("")),
);
} else {
if was_terminal && !status.is_terminal() {
ctx.record_event(EventType::Reopened, id, None);
conn.execute_with_params(
"DELETE FROM close_metadata WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
}
if issue.closed_at.is_some() && updates.closed_at.is_none() {
// Reopening (or fixing state): Clear closed_at if it was set
issue.closed_at = None;
issue.close_reason = None;
issue.closed_by_session = None;
add_update("closed_at", SqliteValue::Null);
add_update("close_reason", SqliteValue::from(""));
add_update("closed_by_session", SqliteValue::from(""));
}
if issue.deleted_at.is_some() {
issue.deleted_at = None;
issue.deleted_by = None;
issue.delete_reason = None;
add_update("deleted_at", SqliteValue::Null);
add_update("deleted_by", SqliteValue::Null);
add_update("delete_reason", SqliteValue::Null);
}
}
if updates.skip_cache_rebuild {
ctx.invalidate_cache_deferred();
} else {
// Direct dependents are collected once for the atomic batch.
// Blocking edges depend on status, not on cached readiness,
// so they do not require recursive dependency traversal.
ctx.invalidate_cache_for(&[id]);
}
}
// Priority
if let Some(priority) = updates.priority {
let old_priority = issue.priority.0;
if priority.0 != old_priority {
issue.priority = priority;
add_update("priority", SqliteValue::from(i64::from(priority.0)));
ctx.record_field_change(
EventType::PriorityChanged,
id,
Some(old_priority.to_string()),
Some(priority.0.to_string()),
None,
);
}
}
// Issue type
if let Some(ref issue_type) = updates.issue_type {
if issue.issue_type != *issue_type {
// Becoming or ceasing to be an epic changes child-open rollup,
// including when another issue changes status in this batch.
if updates.skip_cache_rebuild {
ctx.invalidate_cache_deferred();
} else {
ctx.invalidate_cache_for(&[id]);
}
}
issue.issue_type.clone_from(issue_type);
add_update("issue_type", SqliteValue::from(issue_type.as_str()));
}
// Assignee
if let Some(ref assignee_opt) = updates.assignee {
let old_assignee = issue.assignee.clone();
issue.assignee.clone_from(assignee_opt);
add_update(
"assignee",
assignee_opt
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
);
if old_assignee != *assignee_opt {
ctx.record_field_change(
EventType::AssigneeChanged,
id,
old_assignee,
assignee_opt.clone(),
None,
);
}
}
// Simple Option fields - use empty string instead of NULL for bd compatibility
if let Some(ref val) = updates.owner {
issue.owner.clone_from(val);
add_update("owner", SqliteValue::from(val.as_deref().unwrap_or("")));
}
if let Some(ref val) = updates.estimated_minutes {
issue.estimated_minutes = *val;
add_update(
"estimated_minutes",
val.map_or(SqliteValue::Null, |v| SqliteValue::from(i64::from(v))),
);
}
if let Some(ref val) = updates.external_ref {
// Explicit uniqueness check for fsqlite
if let Some(ext_ref) = val {
let existing_ext = conn.query_with_params(
"SELECT id FROM issues WHERE external_ref = ? AND id != ? LIMIT 1",
&[SqliteValue::from(ext_ref.as_str()), SqliteValue::from(id)],
)?;
if let Some(existing_row) = existing_ext.first() {
let other_id = existing_row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or_default()
.to_string();
return Err(BeadsError::Config(format!(
"External reference '{ext_ref}' already exists on issue {other_id}"
)));
}
}
issue.external_ref.clone_from(val);
add_update(
"external_ref",
val.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
);
}
if let Some(ref val) = updates.source_repo {
// `source_repo` is NOT NULL DEFAULT '.' so an explicit clear
// must fall back to "." rather than SQL NULL — otherwise the
// schema's NOT NULL constraint rejects the write.
let next = val.clone().unwrap_or_else(|| ".".to_string());
issue.source_repo = Some(next.clone());
add_update("source_repo", SqliteValue::from(next.as_str()));
}
if let Some(ref val) = updates.source_repo_path {
issue.source_repo_path.clone_from(val);
add_update(
"source_repo_path",
val.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
);
}
if let Some(ref val) = updates.agent_context {
issue.agent_context.clone_from(val);
add_update(
"agent_context",
val.as_deref().map_or(SqliteValue::Null, SqliteValue::from),
);
}
// Use empty string instead of NULL for bd compatibility
if let Some(ref val) = updates.close_reason {
issue.close_reason.clone_from(val);
add_update(
"close_reason",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
if let Some(ref val) = updates.closed_by_session {
issue.closed_by_session.clone_from(val);
add_update(
"closed_by_session",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
// Tombstone fields
if let Some(ref val) = updates.deleted_at {
issue.deleted_at = *val;
add_update(
"deleted_at",
val.map_or(SqliteValue::Null, |d| SqliteValue::from(d.to_rfc3339())),
);
}
// Use empty string instead of NULL for bd compatibility
if let Some(ref val) = updates.deleted_by {
issue.deleted_by.clone_from(val);
add_update(
"deleted_by",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
if let Some(ref val) = updates.delete_reason {
issue.delete_reason.clone_from(val);
add_update(
"delete_reason",
SqliteValue::from(val.as_deref().unwrap_or("")),
);
}
// Date fields
if let Some(ref val) = updates.due_at {
issue.due_at = *val;
add_update(
"due_at",
val.map_or(SqliteValue::Null, |d| SqliteValue::from(d.to_rfc3339())),
);
}
if let Some(ref val) = updates.defer_until {
issue.defer_until = *val;
add_update(
"defer_until",
val.map_or(SqliteValue::Null, |d| SqliteValue::from(d.to_rfc3339())),
);
}
if let Some(ref val) = updates.closed_at {
issue.closed_at = *val;
add_update(
"closed_at",
val.map_or(SqliteValue::Null, |d| SqliteValue::from(d.to_rfc3339())),
);
}
if set_clauses.is_empty() {
return Ok(());
}
// Update updated_at only when a stored field needs rewriting.
let updated_at = Utc::now();
issue.updated_at = updated_at;
IssueValidator::validate(&issue).map_err(BeadsError::from_validation_errors)?;
set_clauses.push("updated_at = ?".to_string());
params.push(SqliteValue::from(updated_at.to_rfc3339()));
// Update content hash
let new_hash = issue.compute_content_hash();
set_clauses.push("content_hash = ?".to_string());
params.push(SqliteValue::from(new_hash));
// Build and execute SQL. Claim operations use an additional
// compare-and-set predicate so exactly one contender can win even
// if two writers both observed the row as unassigned earlier.
let mut where_clause = "id = ?".to_string();
params.push(SqliteValue::from(id));
if updates.expect_unassigned {
where_clause.push_str(" AND (assignee IS NULL OR TRIM(assignee) = ''");
if !updates.claim_exclusive
&& let Some(claim_actor) = updates
.claim_actor
.as_deref()
.filter(|actor| !actor.is_empty())
{
where_clause.push_str(" OR assignee = ?");
params.push(SqliteValue::from(claim_actor));
}
where_clause.push(')');
}
let sql = format!(
"UPDATE issues SET {} WHERE {where_clause}",
set_clauses.join(", ")
);
let updated_rows = conn.execute_with_params(&sql, ¶ms)?;
if updated_rows == 0 {
if updates.expect_unassigned {
let current_assignee = match conn.query_row_with_params(
"SELECT assignee FROM issues WHERE id = ?",
&[SqliteValue::from(id)],
) {
Ok(row) => row
.get(0)
.and_then(SqliteValue::as_text)
.map(String::from)
.and_then(|assignee| {
let trimmed = assignee.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.unwrap_or_else(|| "<unknown>".to_string()),
Err(FrankenError::QueryReturnedNoRows) => "<unknown>".to_string(),
Err(error) => return Err(error.into()),
};
return Err(BeadsError::validation(
"claim",
format!("issue {id} already assigned to {current_assignee}"),
));
}
return Err(BeadsError::IssueNotFound { id: id.to_string() });
}
ctx.mark_dirty(id);
Ok(())
}
/// Delete an issue by creating a tombstone.
///
/// # Errors
///
/// Returns an error if the issue doesn't exist or the update fails.
pub fn delete_issue(
&mut self,
id: &str,
actor: &str,
reason: &str,
deleted_at: Option<DateTime<Utc>>,
) -> Result<Issue> {
let issue = self
.get_issue(id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.to_string() })?;
if issue.status == Status::Tombstone {
return Ok(issue);
}
let was_terminal = issue.status.is_terminal();
let previous_status = issue.status.as_str().to_string();
let original_type = issue.issue_type.as_str().to_string();
let timestamp = deleted_at.unwrap_or_else(Utc::now);
let mut tombstone_issue = issue;
tombstone_issue.status = Status::Tombstone;
let tombstone_hash = crate::util::content_hash(&tombstone_issue);
let capacity_policy = self.workflow_capacity_policy.clone();
let tombstone_assignee = tombstone_issue.assignee.clone();
self.mutate("delete_issue", actor, |conn, ctx| {
let acting = CapacityActingContext::new(&ctx.actor, &ctx.attribution);
let capacity_warnings = Self::enforce_workflow_capacity_in_tx(
conn,
&capacity_policy,
id,
Some(&previous_status),
"tombstone",
None,
CapacityTransitionAssignee {
current: tombstone_assignee.as_deref(),
prospective: tombstone_assignee.as_deref(),
},
&acting,
)?;
ctx.capacity_warnings.extend(capacity_warnings);
// GitHub #384 phase 4: tombstoning leaves every status, so any
// active exemption whose applicable set contained the previous
// status ends here, atomically with the delete.
Self::end_departed_capacity_exemptions_in_tx(
conn,
&capacity_policy,
id,
&previous_status,
"tombstone",
actor,
)?;
conn.execute_with_params(
"UPDATE issues SET
content_hash = ?,
status = 'tombstone',
deleted_at = ?,
deleted_by = ?,
delete_reason = ?,
original_type = ?,
updated_at = ?
WHERE id = ?",
&[
SqliteValue::from(tombstone_hash.as_str()),
SqliteValue::from(timestamp.to_rfc3339()),
SqliteValue::from(actor),
SqliteValue::from(reason),
SqliteValue::from(original_type.as_str()),
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(id),
],
)?;
conn.execute_with_params(
"DELETE FROM close_metadata WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
// GitHub #384 phase 5: the tombstone transition is a status
// change like any other; record its admission attribution.
Self::record_capacity_occupancy_in_tx(conn, id, &ctx.actor, &ctx.attribution)?;
if !was_terminal {
ctx.record_event(
EventType::Deleted,
id,
Some(format!("Deleted issue: {reason}")),
);
}
ctx.mark_dirty(id);
ctx.invalidate_cache();
Ok(())
})?;
self.get_issue(id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.to_string() })
}
/// Physically remove an issue and all related data from the database.
///
/// Unlike `delete_issue` (which creates a tombstone), this permanently
/// removes the issue row plus its labels, dependencies, comments, and
/// events so it will not appear in subsequent JSONL exports.
///
/// # Errors
///
/// Returns an error if the issue doesn't exist or a database operation fails.
pub fn purge_issue(&mut self, id: &str, actor: &str) -> Result<()> {
if self.get_issue(id)?.is_none() {
return Err(BeadsError::IssueNotFound { id: id.to_string() });
}
self.mutate("purge_issue", actor, |conn, ctx| {
conn.execute_with_params(
"DELETE FROM comments WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM dependencies WHERE depends_on_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM events WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM blocked_issues_cache WHERE issue_id = ?",
&[SqliteValue::from(id)],
)?;
conn.execute_with_params(
"DELETE FROM child_counters WHERE parent_id = ?",
&[SqliteValue::from(id)],
)?;
// Keep hard deletion independent of connection-level foreign-key
// enforcement. The fsqlite connection can legitimately have
// enforcement disabled around schema/recovery work, so relying on
// ON DELETE CASCADE here leaves DB-only workflow evidence orphaned
// after a successful purge (GitHub #453).
for statement in [
"DELETE FROM close_metadata WHERE issue_id = ?",
"DELETE FROM gate_results WHERE issue_id = ?",
"DELETE FROM gate_result_history WHERE issue_id = ?",
"DELETE FROM capacity_exemptions WHERE issue_id = ?",
"DELETE FROM capacity_exemption_history WHERE issue_id = ?",
"DELETE FROM capacity_occupancy WHERE issue_id = ?",
] {
conn.execute_with_params(statement, &[SqliteValue::from(id)])?;
}
conn.execute_with_params("DELETE FROM issues WHERE id = ?", &[SqliteValue::from(id)])?;
// Record the intentional removal so the exporter's stale-database
// guard can distinguish "purged on purpose" from "never imported"
// (#405). Without this, a post-purge flush would need blanket
// force semantics, which disables the data-loss guard entirely.
Self::record_purged_id_pending_export_in_tx(conn, id)?;
ctx.invalidate_cache();
ctx.force_flush = true;
Ok(())
})
}
/// List the IDs of all tombstoned (soft-deleted) issues, sorted.
///
/// Used by `br delete --hard` (invoked with no explicit IDs) to purge every
/// tombstone from the store in one pass (#367).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_tombstone_ids(&self) -> Result<Vec<String>> {
let rows = self
.conn
.query("SELECT id FROM issues WHERE status = 'tombstone' ORDER BY id")?;
let mut ids = Vec::with_capacity(rows.len());
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
ids.push(id.to_string());
}
}
Ok(ids)
}
/// Get an issue by ID.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issue(&self, id: &str) -> Result<Option<Issue>> {
Self::get_issue_from_conn(&self.conn, id)
}
/// Get metadata for all issues to optimize import collision detection.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_issues_metadata(&self) -> Result<Vec<IssueMetadata>> {
let sql = "SELECT id, external_ref, content_hash, updated_at, status FROM issues";
let rows = self.conn.query(sql)?;
let mut metas = Vec::with_capacity(rows.len());
for row in &rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or_default()
.to_string();
let external_ref = row
.get(1)
.and_then(SqliteValue::as_text)
.map(str::to_string);
let content_hash = row
.get(2)
.and_then(SqliteValue::as_text)
.map(str::to_string);
let updated_at = parse_datetime_value(row.get(3))?;
let status = parse_status(row.get(4).and_then(SqliteValue::as_text));
metas.push(IssueMetadata {
id,
external_ref,
content_hash,
updated_at,
status,
});
}
Ok(metas)
}
/// Return issue IDs with the requested status without parsing unrelated
/// issue metadata columns.
///
/// This is intentionally narrower than `get_all_issues_metadata()`: callers
/// that only need tombstone IDs should not fail because some other issue row
/// has a malformed timestamp or other metadata decoding problem.
///
/// # Errors
///
/// Returns an error if the query fails.
pub fn get_issue_ids_by_status(&self, status: &Status) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
"SELECT id FROM issues WHERE status = ? ORDER BY id",
&[SqliteValue::from(status.as_str())],
)?;
Ok(rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text))
.map(str::to_string)
.collect())
}
fn get_issue_from_conn(conn: &Connection, id: &str) -> Result<Option<Issue>> {
let sql = r"
SELECT 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
FROM issues
WHERE id = ?
";
let row = match conn.query_row_with_params(sql, &[SqliteValue::from(id)]) {
Ok(row) => row,
Err(FrankenError::QueryReturnedNoRows) => return Ok(None),
Err(error) => return Err(error.into()),
};
let issue = Self::issue_from_row(&row)?;
if issue.id != id {
return Err(BeadsError::internal(format!(
"storage consistency: get_issue_from_conn requested {id:?} but row returned id {:?}",
issue.id
)));
}
Ok(Some(issue))
}
/// Get multiple issues by ID.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issues_by_ids(&self, ids: &[String]) -> Result<Vec<Issue>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let mut issues = Vec::new();
for chunk in ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
r"SELECT 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
FROM issues WHERE id IN ({})",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|s| SqliteValue::from(s.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
issues.push(Self::issue_from_row(row)?);
}
}
Ok(issues)
}
fn label_filter_candidate_ids(
&self,
labels_and: &[String],
labels_or: &[String],
) -> Result<Option<Vec<String>>> {
let all_label_ids = self.query_issue_ids_with_all_labels(labels_and)?;
let any_label_ids = self.query_issue_ids_with_any_label(labels_or)?;
let ids = match (all_label_ids, any_label_ids) {
(None, None) => return Ok(None),
(Some(ids), None) | (None, Some(ids)) => ids,
(Some(all_ids), Some(any_ids)) => {
let any_id_set: HashSet<&str> = any_ids.iter().map(String::as_str).collect();
all_ids
.into_iter()
.filter(|issue_id| any_id_set.contains(issue_id.as_str()))
.collect()
}
};
Ok(Some(ids))
}
fn query_issue_ids_with_all_labels(&self, labels: &[String]) -> Result<Option<Vec<String>>> {
let unique_labels = unique_label_refs(labels);
let ids = match unique_labels.as_slice() {
[] => return Ok(None),
[label] => self.query_issue_ids_from_label_sql(
"SELECT issue_id FROM labels WHERE label = ? ORDER BY issue_id",
&[SqliteValue::from(label.as_str())],
)?,
_ => {
let placeholders: Vec<String> =
unique_labels.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT issue_id
FROM labels
WHERE label IN ({})
GROUP BY issue_id
HAVING COUNT(DISTINCT label) = ?
ORDER BY issue_id",
placeholders.join(",")
);
let unique_label_count = unique_labels.len();
let mut params = Vec::with_capacity(unique_labels.len() + 1);
for label in unique_labels {
params.push(SqliteValue::from(label.as_str()));
}
params.push(SqliteValue::from(
i64::try_from(unique_label_count).unwrap_or(i64::MAX),
));
self.query_issue_ids_from_label_sql(&sql, ¶ms)?
}
};
Ok(Some(ids))
}
fn query_issue_ids_with_any_label(&self, labels: &[String]) -> Result<Option<Vec<String>>> {
if labels.is_empty() {
return Ok(None);
}
let placeholders: Vec<String> = labels.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT DISTINCT issue_id
FROM labels
WHERE label IN ({})
ORDER BY issue_id",
placeholders.join(",")
);
let params: Vec<SqliteValue> = labels
.iter()
.map(|label| SqliteValue::from(label.as_str()))
.collect();
Ok(Some(self.query_issue_ids_from_label_sql(&sql, ¶ms)?))
}
fn query_issue_ids_from_label_sql(
&self,
sql: &str,
params: &[SqliteValue],
) -> Result<Vec<String>> {
self.query_issue_ids_from_sql(sql, params)
}
fn query_issue_ids_from_sql(&self, sql: &str, params: &[SqliteValue]) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(sql, params)?;
Ok(rows
.iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect())
}
/// List issues with optional filters.
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::too_many_lines)]
pub fn list_issues(&self, filters: &ListFilters) -> Result<Vec<Issue>> {
if let Some(limit) = default_visible_limited_page_limit(filters) {
return self.list_default_visible_limited_page(filters.include_deferred, limit);
}
let sort_default_in_rust = should_sort_list_default_in_rust(filters);
let mut sql = String::from(
r"SELECT 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",
);
let mut params: Vec<SqliteValue> = Vec::new();
let labels_and = filters.labels.as_deref().unwrap_or(&[]);
let labels_or = filters.labels_or.as_deref().unwrap_or(&[]);
let label_candidate_ids = if labels_and.is_empty() && labels_or.is_empty() {
None
} else {
self.label_filter_candidate_ids(labels_and, labels_or)?
};
if label_candidate_ids.as_ref().is_some_and(Vec::is_empty) {
return Ok(Vec::new());
}
if self.redundant_default_visible_single_label_filter(
filters,
label_candidate_ids.as_deref(),
)? {
let mut filters_without_redundant_label = filters.clone();
filters_without_redundant_label.labels = None;
return self.list_issues(&filters_without_redundant_label);
}
sql.push_str(" FROM issues WHERE 1=1");
if let Some(ref issue_ids) = label_candidate_ids {
append_issue_id_membership_filter(&mut sql, &mut params, issue_ids);
}
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({}) ", placeholders.join(","));
for s in statuses {
params.push(SqliteValue::from(s.as_str()));
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({}) ", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({}) ", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
// When including closed issues, still exclude tombstones (deleted issues) by default
// unless specific statuses were requested.
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(ref title_contains) = filters.title_contains {
sql.push_str(" AND title LIKE ? ESCAPE '\\'");
let escaped = escape_like_pattern(title_contains);
params.push(SqliteValue::from(format!("%{escaped}%")));
}
if let Some(ts) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if let Some(ts) = filters.updated_after {
sql.push_str(" AND updated_at >= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if !sort_default_in_rust {
// Apply custom sort if provided
if let Some(ref sort_field) = filters.sort {
let order = if filters.reverse { "DESC" } else { "ASC" };
// Simple validation to prevent injection (though params should handle it,
// column names can't be parameterized)
match sort_field.as_str() {
"priority" => {
let secondary_order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(
sql,
" ORDER BY priority {order}, created_at {secondary_order}, id ASC"
);
}
"created_at" | "created" => {
let order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(sql, " ORDER BY created_at {order}, id ASC");
}
"updated_at" | "updated" => {
let order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(sql, " ORDER BY updated_at {order}, id ASC");
}
"title" => {
// Case-insensitive sort for title
let _ = write!(sql, " ORDER BY title COLLATE NOCASE {order}, id ASC");
}
_ => {
// Default fallback
sql.push_str(" ORDER BY priority ASC, created_at DESC, id ASC");
}
}
} else if filters.reverse {
sql.push_str(" ORDER BY priority DESC, created_at ASC, id ASC");
} else {
sql.push_str(" ORDER BY priority ASC, created_at DESC, id ASC");
}
}
match (filters.limit, filters.offset) {
(Some(limit), offset) if limit > 0 => {
let _ = write!(sql, " LIMIT {limit}");
if let Some(offset) = offset
&& offset > 0
{
let _ = write!(sql, " OFFSET {offset}");
}
}
(_, Some(offset)) if offset > 0 => {
let _ = write!(sql, " LIMIT -1 OFFSET {offset}");
}
_ => {}
}
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::issue_from_row(row)?);
}
if sort_default_in_rust {
sort_list_default(&mut issues);
}
Ok(issues)
}
fn list_default_visible_limited_page(
&self,
include_deferred: bool,
limit: usize,
) -> Result<Vec<Issue>> {
let status_filter = if include_deferred {
"status NOT IN ('closed', 'tombstone')"
} else {
"status NOT IN ('closed', 'tombstone', 'deferred')"
};
let mut issues = Vec::with_capacity(limit);
for priority in Priority::CRITICAL.0..=Priority::BACKLOG.0 {
let remaining = limit.saturating_sub(issues.len());
if remaining == 0 {
break;
}
// `is_template = 0` (not the `OR IS NULL` spelling) so stock
// SQLite can serve this from `idx_issues_list_active_order`; see
// `list_text_issues_by_priority_window` (#463).
let sql = format!(
r"SELECT 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
FROM issues
WHERE {status_filter}
AND is_template = 0
AND priority = ?
ORDER BY created_at DESC, id ASC
LIMIT {remaining}"
);
let rows = self
.conn
.query_with_params(&sql, &[SqliteValue::from(i64::from(priority))])?;
for row in &rows {
issues.push(Self::issue_from_row(row)?);
}
}
Ok(issues)
}
/// List short text/table command issues without hydrating fields the renderer never inspects.
///
/// This is intentionally narrow and falls back to `list_issues` if the
/// filter shape expands beyond the unlimited default short text path.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_text_issues_for_command_output(&self, filters: &ListFilters) -> Result<Vec<Issue>> {
let unsupported_filter = filters
.statuses
.as_ref()
.is_some_and(|statuses| !statuses.is_empty())
|| filters
.labels
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.labels_or
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.types
.as_ref()
.is_some_and(|types| !types.is_empty())
|| filters
.priorities
.as_ref()
.is_some_and(|priorities| !priorities.is_empty())
|| filters.assignee.is_some()
|| filters.unassigned
|| filters.include_closed
|| !filters.include_deferred
|| filters.include_templates
|| filters.title_contains.is_some()
|| filters.updated_before.is_some()
|| filters.updated_after.is_some()
|| filters.sort.is_some()
|| filters.reverse;
if unsupported_filter {
return self.list_issues(filters);
}
if let Some(limit) = filters.limit
&& limit > 0
{
return self.list_text_issues_by_priority_window(filters, limit);
}
let mut sql = String::from(
"SELECT id, title, status, priority, issue_type, created_at, updated_at
FROM issues
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL)
ORDER BY COALESCE(priority, 2) ASC, created_at DESC, id ASC",
);
match (filters.limit, filters.offset) {
(Some(limit), offset) if limit > 0 => {
let _ = write!(sql, " LIMIT {limit}");
if let Some(offset) = offset
&& offset > 0
{
let _ = write!(sql, " OFFSET {offset}");
}
}
(_, Some(offset)) if offset > 0 => {
let _ = write!(sql, " LIMIT -1 OFFSET {offset}");
}
_ => {}
}
let rows = self.conn.query(&sql)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::command_summary_issue_from_row(row)?);
}
Ok(issues)
}
fn list_text_issues_by_priority_window(
&self,
filters: &ListFilters,
limit: usize,
) -> Result<Vec<Issue>> {
let mut offset = filters.offset.unwrap_or(0);
let mut issues = Vec::with_capacity(limit);
for priority in Priority::CRITICAL.0..=Priority::BACKLOG.0 {
let remaining = limit - issues.len();
if remaining == 0 {
break;
}
let query_limit = remaining.saturating_add(offset);
// Spell the template predicate as the bare `is_template = 0` so the
// planner can prove it implies `idx_issues_list_active_order`'s
// partial-index predicate: `is_template` is `NOT NULL`, so stock
// SQLite folds `is_template IS NULL` to a constant at resolve time
// and the `(… OR is_template IS NULL)` spelling no longer matches
// the index's stored predicate. No `INDEXED BY`: when the planner
// cannot use a hinted index it fails the whole statement with
// "no query solution" instead of picking another plan (#463).
let rows = self.conn.query_with_params(
"SELECT id, title, status, priority, issue_type, created_at, updated_at
FROM issues
WHERE status NOT IN ('closed', 'tombstone')
AND is_template = 0
AND priority = ?
ORDER BY created_at DESC, id ASC
LIMIT ?",
&[
SqliteValue::from(i64::from(priority)),
SqliteValue::from(i64::try_from(query_limit).unwrap_or(i64::MAX)),
],
)?;
if offset >= rows.len() {
offset -= rows.len();
continue;
}
for row in rows.iter().skip(offset) {
if issues.len() == limit {
break;
}
issues.push(Self::command_summary_issue_from_row(row)?);
}
offset = 0;
}
Ok(issues)
}
/// List stale command issues without hydrating fields stale output never renders.
///
/// This is intentionally narrow and falls back to `list_issues` if the
/// filter shape expands beyond what `br stale` currently uses.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_stale_issues_for_command_output(
&self,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
let unsupported_filter = filters
.labels
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.labels_or
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.types
.as_ref()
.is_some_and(|types| !types.is_empty())
|| filters
.priorities
.as_ref()
.is_some_and(|priorities| !priorities.is_empty())
|| filters.assignee.is_some()
|| filters.unassigned
|| filters.title_contains.is_some()
|| filters.updated_after.is_some()
|| filters.sort.as_deref() != Some("updated_at")
|| !filters.reverse;
if unsupported_filter {
return self.list_issues(filters);
}
let mut sql = String::from(
"SELECT id, title, status, priority, issue_type, assignee, created_at, updated_at
FROM issues WHERE 1=1",
);
let mut params = Vec::new();
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({}) ", placeholders.join(","));
for status in statuses {
params.push(SqliteValue::from(status.as_str()));
}
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(updated_before) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(updated_before.to_rfc3339()));
}
sql.push_str(" ORDER BY updated_at ASC, id ASC");
match (filters.limit, filters.offset) {
(Some(limit), offset) if limit > 0 => {
let _ = write!(sql, " LIMIT {limit}");
if let Some(offset) = offset
&& offset > 0
{
let _ = write!(sql, " OFFSET {offset}");
}
}
(_, Some(offset)) if offset > 0 => {
let _ = write!(sql, " LIMIT -1 OFFSET {offset}");
}
_ => {}
}
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::stale_command_issue_from_row(row)?);
}
Ok(issues)
}
/// List lint command issues without hydrating fields lint never inspects.
///
/// This is intentionally narrow and falls back to `list_issues` if the
/// filter shape expands beyond what `br lint` currently uses.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_lint_issues_for_command_output(&self, filters: &ListFilters) -> Result<Vec<Issue>> {
let unsupported_filter = filters
.labels
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.labels_or
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.priorities
.as_ref()
.is_some_and(|priorities| !priorities.is_empty())
|| filters.assignee.is_some()
|| filters.unassigned
|| filters.title_contains.is_some()
|| filters.updated_before.is_some()
|| filters.updated_after.is_some()
|| filters.limit.is_some()
|| filters.offset.is_some()
|| filters.sort.is_some()
|| filters.reverse;
if unsupported_filter {
return self.list_issues(filters);
}
let mut sql = String::from(
"SELECT id, title, description, status, issue_type, created_at, updated_at
FROM issues WHERE 1=1",
);
let mut params = Vec::new();
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({}) ", placeholders.join(","));
for status in statuses {
params.push(SqliteValue::from(status.as_str()));
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({}) ", placeholders.join(","));
for issue_type in types {
params.push(SqliteValue::from(issue_type.as_str()));
}
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
sql.push_str(" ORDER BY priority ASC, created_at DESC, id ASC");
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::lint_command_issue_from_row(row)?);
}
Ok(issues)
}
/// List orphan-scan candidate issues without hydrating unused full issue fields.
///
/// This is intentionally narrow and falls back to `list_issues` if the
/// filter shape expands beyond what `br orphans` currently uses.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_orphan_candidate_issues_for_command_output(
&self,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
let expected_statuses = matches!(
filters.statuses.as_deref(),
Some([Status::Open, Status::InProgress])
);
let unsupported_filter = !expected_statuses
|| filters
.labels
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.labels_or
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.types
.as_ref()
.is_some_and(|types| !types.is_empty())
|| filters
.priorities
.as_ref()
.is_some_and(|priorities| !priorities.is_empty())
|| filters.assignee.is_some()
|| filters.unassigned
|| filters.include_closed
|| filters.include_deferred
|| filters.include_templates
|| filters.title_contains.is_some()
|| filters.updated_before.is_some()
|| filters.updated_after.is_some()
|| filters.limit.is_some()
|| filters.offset.is_some()
|| filters.sort.is_some()
|| filters.reverse;
if unsupported_filter {
return self.list_issues(filters);
}
let rows = self.conn.query(
"SELECT id, title, status, priority, issue_type, created_at, updated_at
FROM issues
WHERE status IN ('open', 'in_progress')
AND (is_template = 0 OR is_template IS NULL)
ORDER BY COALESCE(priority, 2) ASC, created_at DESC, id ASC",
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::command_summary_issue_from_row(row)?);
}
Ok(issues)
}
/// List graph command issues without hydrating fields graph rendering never inspects.
///
/// This is intentionally narrow and falls back to `list_issues` if the
/// filter shape expands beyond what `br graph --all` currently uses.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_graph_issues_for_command_output(
&self,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
let unsupported_filter = filters
.statuses
.as_ref()
.is_some_and(|statuses| !statuses.is_empty())
|| filters
.labels
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.labels_or
.as_ref()
.is_some_and(|labels| !labels.is_empty())
|| filters
.types
.as_ref()
.is_some_and(|types| !types.is_empty())
|| filters
.priorities
.as_ref()
.is_some_and(|priorities| !priorities.is_empty())
|| filters.assignee.is_some()
|| filters.unassigned
|| filters.include_closed
|| !filters.include_deferred
|| filters.include_templates
|| filters.title_contains.is_some()
|| filters.updated_before.is_some()
|| filters.updated_after.is_some()
|| filters.limit.is_some()
|| filters.offset.is_some()
|| filters.sort.is_some()
|| filters.reverse;
if unsupported_filter {
return self.list_issues(filters);
}
let rows = self.conn.query(
"SELECT id, title, status, priority, issue_type, created_at, updated_at
FROM issues
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL)
ORDER BY COALESCE(priority, 2) ASC, created_at DESC, id ASC",
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::command_summary_issue_from_row(row)?);
}
Ok(issues)
}
/// Get lean issue rows for stats computation without hydrating large text fields.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_stats_issues(&self) -> Result<Vec<StatsIssueRow>> {
let rows = self.conn.query(
r"SELECT id, status, priority, issue_type, assignee, created_at, closed_at,
defer_until, ephemeral, pinned, is_template
FROM issues",
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::stats_issue_from_row(row)?);
}
Ok(issues)
}
/// Get the narrowest issue rows needed for stats summary computation.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn list_stats_summary_issues(&self) -> Result<Vec<StatsIssueRow>> {
let rows = self.conn.query(
r"SELECT id, status, issue_type, created_at, closed_at,
defer_until, ephemeral, pinned, is_template
FROM issues",
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::stats_summary_issue_from_row(row)?);
}
Ok(issues)
}
/// Get lean closed issue rows for changelog rendering.
///
/// # Errors
///
/// Returns an error if the database query fails or a stored timestamp is invalid.
pub(crate) fn list_changelog_issues(&self) -> Result<Vec<ChangelogIssueRow>> {
let rows = self.conn.query(
r"SELECT id, title, priority, issue_type, created_at, closed_at
FROM issues
WHERE status = 'closed'
AND (is_template = 0 OR is_template IS NULL)",
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::changelog_issue_from_row(row)?);
}
issues.sort_unstable_by(|left, right| {
left.priority
.cmp(&right.priority)
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.id.cmp(&right.id))
});
Ok(issues)
}
/// Count issues matching the given filters (no LIMIT/OFFSET applied).
///
/// Runs a `SELECT COUNT(*)` using the same WHERE conditions as [`list_issues`],
/// without ORDER BY, LIMIT, or OFFSET clauses. Used to compute pagination metadata.
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::too_many_lines)]
pub fn count_issues_with_filters(&self, filters: &ListFilters) -> Result<usize> {
if let Some(label) = default_visible_single_label_count_filter(filters) {
return self.count_default_visible_single_label(filters.include_deferred, label);
}
let mut sql = String::from("SELECT COUNT(*)");
let mut params: Vec<SqliteValue> = Vec::new();
let labels_and = filters.labels.as_deref().unwrap_or(&[]);
let labels_or = filters.labels_or.as_deref().unwrap_or(&[]);
let label_filters_can_use_uncorrelated_in =
filters.statuses.as_ref().is_none_or(Vec::is_empty)
&& filters.types.as_ref().is_none_or(Vec::is_empty)
&& filters.priorities.as_ref().is_none_or(Vec::is_empty)
&& filters.assignee.is_none()
&& filters.title_contains.is_none()
&& filters.updated_before.is_none()
&& filters.updated_after.is_none();
let label_candidate_ids = if label_filters_can_use_uncorrelated_in {
None
} else {
self.label_filter_candidate_ids(labels_and, labels_or)?
};
if label_candidate_ids.as_ref().is_some_and(Vec::is_empty) {
return Ok(0);
}
sql.push_str(" FROM issues WHERE 1=1");
if label_filters_can_use_uncorrelated_in {
append_label_membership_filters(&mut sql, &mut params, labels_and, labels_or);
} else if let Some(ref issue_ids) = label_candidate_ids {
append_issue_id_membership_filter(&mut sql, &mut params, issue_ids);
}
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({}) ", placeholders.join(","));
for s in statuses {
params.push(SqliteValue::from(s.as_str()));
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({}) ", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({}) ", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(ref title_contains) = filters.title_contains {
sql.push_str(" AND title LIKE ? ESCAPE '\\'");
let escaped = escape_like_pattern(title_contains);
params.push(SqliteValue::from(format!("%{escaped}%")));
}
if let Some(ts) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if let Some(ts) = filters.updated_after {
sql.push_str(" AND updated_at >= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
let row = self.conn.query_row_with_params(&sql, ¶ms)?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
fn count_default_visible_single_label(
&self,
include_deferred: bool,
label: &str,
) -> Result<usize> {
let status_filter = if include_deferred {
"status NOT IN ('closed', 'tombstone')"
} else {
"status NOT IN ('closed', 'tombstone', 'deferred')"
};
let issue_rows = self.conn.query(&format!(
"SELECT id
FROM issues
WHERE {status_filter}
AND (is_template = 0 OR is_template IS NULL)"
))?;
if issue_rows.is_empty() {
return Ok(0);
}
let mut visible_ids = HashSet::with_capacity(issue_rows.len());
for row in &issue_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
if !issue_id.is_empty() {
visible_ids.insert(issue_id);
}
}
let label_rows = self.conn.query_with_params(
"SELECT issue_id FROM labels WHERE label = ?",
&[SqliteValue::from(label)],
)?;
Ok(label_rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text))
.filter(|issue_id| visible_ids.contains(*issue_id))
.count())
}
fn single_label_covers_default_visible_issues(
&self,
include_deferred: bool,
label: &str,
) -> Result<bool> {
let total = self.count_default_visible_issues(include_deferred)?;
let labeled = self.count_default_visible_single_label(include_deferred, label)?;
Ok(labeled == total)
}
fn count_default_visible_issues(&self, include_deferred: bool) -> Result<usize> {
let status_filter = if include_deferred {
"status NOT IN ('closed', 'tombstone')"
} else {
"status NOT IN ('closed', 'tombstone', 'deferred')"
};
let row = self.conn.query_row(&format!(
"SELECT COUNT(*)
FROM issues
WHERE {status_filter}
AND (is_template = 0 OR is_template IS NULL)"
))?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
fn redundant_default_visible_single_label_filter(
&self,
filters: &ListFilters,
label_candidate_ids: Option<&[String]>,
) -> Result<bool> {
let Some(label) = default_visible_single_label_count_filter(filters) else {
return Ok(false);
};
let Some(issue_ids) = label_candidate_ids else {
return Ok(false);
};
if issue_ids.len() < REDUNDANT_LABEL_COVERAGE_MIN_CANDIDATES {
return Ok(false);
}
self.single_label_covers_default_visible_issues(filters.include_deferred, label)
}
/// Count default-visible issues grouped by status.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_default_visible_statuses(&self) -> Result<Vec<(String, usize)>> {
self.count_default_visible_text_groups("status", "status")
}
/// Every status value currently present in the issues table, including
/// custom workflow statuses and terminal states.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn distinct_statuses(&self) -> Result<Vec<String>> {
let rows = self
.conn
.query("SELECT DISTINCT status FROM issues ORDER BY status")?;
Ok(rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text))
.map(str::to_string)
.collect())
}
/// Count default-visible issues grouped by issue type.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_default_visible_types(&self) -> Result<Vec<(String, usize)>> {
self.count_default_visible_text_groups("issue_type", "issue_type")
}
/// Count default-visible issues grouped by assignee.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_default_visible_assignees(&self) -> Result<Vec<(String, usize)>> {
self.count_default_visible_text_groups(
"COALESCE(NULLIF(assignee, ''), '(unassigned)')",
"COALESCE(NULLIF(assignee, ''), '(unassigned)')",
)
}
/// Count default-visible issues grouped by priority.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_default_visible_priorities(&self) -> Result<Vec<(String, usize)>> {
let rows = self.conn.query(
"SELECT priority, COUNT(*)
FROM issues
WHERE status NOT IN ('closed', 'tombstone', 'deferred')
AND (is_template = 0 OR is_template IS NULL)
GROUP BY priority
ORDER BY priority",
)?;
rows.iter()
.map(|row| {
let priority = row
.get(0)
.and_then(SqliteValue::as_integer)
.and_then(|value| i32::try_from(value).ok())
.map(Priority)
.unwrap_or_default();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok((priority.to_string(), usize::try_from(count).unwrap_or(0)))
})
.collect()
}
/// Count default-visible issues grouped by label.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_default_visible_labels(&self) -> Result<(usize, Vec<(String, usize)>)> {
let issue_rows = self.conn.query(
"SELECT id
FROM issues
WHERE status NOT IN ('closed', 'tombstone', 'deferred')
AND (is_template = 0 OR is_template IS NULL)",
)?;
let total = issue_rows.len();
if total == 0 {
return Ok((0, Vec::new()));
}
let mut visible_ids = HashSet::with_capacity(total);
for row in &issue_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
if !issue_id.is_empty() {
visible_ids.insert(issue_id);
}
}
let label_rows = self
.conn
.query("SELECT issue_id, label FROM labels ORDER BY issue_id, label")?;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut labeled_visible_issues = 0usize;
let mut last_labeled_issue_id = String::new();
for row in &label_rows {
let issue_id = row.get(0).and_then(SqliteValue::as_text).unwrap_or("");
if !visible_ids.contains(issue_id) {
continue;
}
if issue_id != last_labeled_issue_id {
labeled_visible_issues += 1;
last_labeled_issue_id.clear();
last_labeled_issue_id.push_str(issue_id);
}
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
*counts.entry(label).or_insert(0) += 1;
}
let unlabeled = total.saturating_sub(labeled_visible_issues);
if unlabeled > 0 {
counts.insert("(no labels)".to_string(), unlabeled);
}
Ok((total, counts.into_iter().collect()))
}
fn count_default_visible_text_groups(
&self,
select_expr: &str,
order_expr: &str,
) -> Result<Vec<(String, usize)>> {
let sql = format!(
"SELECT {select_expr}, COUNT(*)
FROM issues
WHERE status NOT IN ('closed', 'tombstone', 'deferred')
AND (is_template = 0 OR is_template IS NULL)
GROUP BY {select_expr}
ORDER BY {order_expr}"
);
let rows = self.conn.query(&sql)?;
rows.iter()
.map(|row| {
let group = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok((group, usize::try_from(count).unwrap_or(0)))
})
.collect()
}
/// Count label buckets for issues matching the given filters.
///
/// This mirrors the label grouping semantics used by `br count --by label`:
/// each labeled issue contributes once per label, and unlabeled issues
/// contribute to the synthetic `(no labels)` bucket.
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::too_many_lines)]
pub fn count_labels_with_filters(&self, filters: &ListFilters) -> Result<Vec<(String, usize)>> {
let mut sql = String::from(
"SELECT labels.label, COUNT(*)
FROM issues
LEFT JOIN labels ON labels.issue_id = issues.id
WHERE 1=1",
);
let mut params: Vec<SqliteValue> = Vec::new();
let label_filters_can_use_uncorrelated_in =
filters.statuses.as_ref().is_none_or(Vec::is_empty)
&& filters.types.as_ref().is_none_or(Vec::is_empty)
&& filters.priorities.as_ref().is_none_or(Vec::is_empty)
&& filters.assignee.is_none()
&& filters.title_contains.is_none()
&& filters.updated_before.is_none()
&& filters.updated_after.is_none();
let label_candidate_ids = if label_filters_can_use_uncorrelated_in {
None
} else {
self.label_filter_candidate_ids(
filters.labels.as_deref().unwrap_or(&[]),
filters.labels_or.as_deref().unwrap_or(&[]),
)?
};
if label_candidate_ids.as_ref().is_some_and(Vec::is_empty) {
return Ok(Vec::new());
}
if label_filters_can_use_uncorrelated_in {
append_label_membership_filters(
&mut sql,
&mut params,
filters.labels.as_deref().unwrap_or(&[]),
filters.labels_or.as_deref().unwrap_or(&[]),
);
} else if let Some(ref issue_ids) = label_candidate_ids {
append_issue_id_membership_filter(&mut sql, &mut params, issue_ids);
}
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({}) ", placeholders.join(","));
for s in statuses {
params.push(SqliteValue::from(s.as_str()));
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({}) ", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({}) ", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(ref title_contains) = filters.title_contains {
sql.push_str(" AND title LIKE ? ESCAPE '\\'");
let escaped = escape_like_pattern(title_contains);
params.push(SqliteValue::from(format!("%{escaped}%")));
}
if let Some(ts) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if let Some(ts) = filters.updated_after {
sql.push_str(" AND updated_at >= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
sql.push_str(" GROUP BY labels.label ORDER BY labels.label");
let rows = self.conn.query_with_params(&sql, ¶ms)?;
Ok(rows
.iter()
.filter_map(|row| {
let label = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("(no labels)")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer)?;
Some((label, usize::try_from(count).unwrap_or(0)))
})
.collect())
}
/// Search issues by query with optional filters.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn search_issues(&self, query: &str, filters: &ListFilters) -> Result<Vec<Issue>> {
self.search_issues_with_projection(query, filters, SearchIssueProjection::Full)
}
/// Search command issues without hydrating fields text/rich search output never renders.
///
/// This keeps `description` for rich context snippets while omitting large
/// structured fields used only by JSON/TOON/CSV or client-side filters.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn search_issues_for_command_output(
&self,
query: &str,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
self.search_issues_with_projection(query, filters, SearchIssueProjection::CommandText)
}
#[allow(clippy::too_many_lines)]
fn search_issues_with_projection(
&self,
query: &str,
filters: &ListFilters,
projection: SearchIssueProjection,
) -> Result<Vec<Issue>> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
if let Some(limit) = default_visible_limited_page_limit(filters) {
return self.search_default_visible_limited_page(
trimmed,
filters.include_deferred,
limit,
projection,
);
}
let mut sql = String::from(projection.select_clause());
let mut params: Vec<SqliteValue> = Vec::new();
let labels_and = filters.labels.as_deref().unwrap_or(&[]);
let labels_or = filters.labels_or.as_deref().unwrap_or(&[]);
let label_candidate_ids = if labels_and.is_empty() && labels_or.is_empty() {
None
} else {
self.label_filter_candidate_ids(labels_and, labels_or)?
};
if label_candidate_ids.as_ref().is_some_and(Vec::is_empty) {
return Ok(Vec::new());
}
if self.redundant_default_visible_single_label_filter(
filters,
label_candidate_ids.as_deref(),
)? {
let mut filters_without_redundant_label = filters.clone();
filters_without_redundant_label.labels = None;
return self.search_issues_with_projection(
trimmed,
&filters_without_redundant_label,
projection,
);
}
if let Some(ref issue_ids) = label_candidate_ids {
append_issue_id_membership_filter(&mut sql, &mut params, issue_ids);
}
if let Some(ref statuses) = filters.statuses
&& !statuses.is_empty()
{
let placeholders: Vec<String> = statuses.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND status IN ({})", placeholders.join(","));
for s in statuses {
params.push(SqliteValue::from(s.as_str()));
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({})", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({})", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
if !filters.include_closed {
if filters.include_deferred {
sql.push_str(" AND status NOT IN ('closed', 'tombstone')");
} else {
sql.push_str(" AND status NOT IN ('closed', 'tombstone', 'deferred')");
}
} else if filters.statuses.as_ref().is_none_or(Vec::is_empty) {
// When including closed issues, still exclude tombstones (deleted issues) by default
// unless specific statuses were requested.
sql.push_str(" AND status != 'tombstone'");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(ref title_contains) = filters.title_contains {
sql.push_str(" AND title LIKE ? ESCAPE '\\'");
let escaped = escape_like_pattern(title_contains);
params.push(SqliteValue::from(format!("%{escaped}%")));
}
if let Some(ts) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if let Some(ts) = filters.updated_after {
sql.push_str(" AND updated_at >= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
sql.push_str(" AND ");
sql.push_str(SEARCH_NEEDLE_PREDICATE);
let needle = trimmed.to_ascii_lowercase();
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle));
if let Some(ref sort_field) = filters.sort {
let order = if filters.reverse { "DESC" } else { "ASC" };
match sort_field.as_str() {
"priority" => {
let secondary_order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(
sql,
" ORDER BY priority {order}, created_at {secondary_order}, id ASC"
);
}
"created_at" | "created" => {
let order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(sql, " ORDER BY created_at {order}, id ASC");
}
"updated_at" | "updated" => {
let order = if filters.reverse { "ASC" } else { "DESC" };
let _ = write!(sql, " ORDER BY updated_at {order}, id ASC");
}
"title" => {
let _ = write!(sql, " ORDER BY title COLLATE NOCASE {order}, id ASC");
}
_ => {
if filters.reverse {
sql.push_str(" ORDER BY priority DESC, created_at ASC, id ASC");
} else {
sql.push_str(" ORDER BY priority ASC, created_at DESC, id ASC");
}
}
}
} else if filters.reverse {
sql.push_str(" ORDER BY priority DESC, created_at ASC, id ASC");
} else {
sql.push_str(" ORDER BY priority ASC, created_at DESC, id ASC");
}
match (filters.limit, filters.offset) {
(Some(limit), offset) if limit > 0 => {
let _ = write!(sql, " LIMIT {limit}");
if let Some(offset) = offset
&& offset > 0
{
let _ = write!(sql, " OFFSET {offset}");
}
}
(_, Some(offset)) if offset > 0 => {
let _ = write!(sql, " LIMIT -1 OFFSET {offset}");
}
_ => {}
}
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(projection.parse_issue(row)?);
}
Ok(issues)
}
/// Count closed issues matching a search query plus the given non-status
/// filters (beads_rust#445: `br search` hides closed issues by default,
/// and callers report how many matches that exclusion hid).
///
/// Counts `status = 'closed'` only — tombstones are deleted issues and
/// stay hidden everywhere. Ignores `limit`/`offset`/sort; applies the
/// same label, type, priority, assignee, and title filters as
/// [`Self::search_issues`].
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_closed_search_matches(&self, query: &str, filters: &ListFilters) -> Result<usize> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Ok(0);
}
let mut sql = String::from("SELECT COUNT(*) FROM issues WHERE status = 'closed'");
let mut params: Vec<SqliteValue> = Vec::new();
let labels_and = filters.labels.as_deref().unwrap_or(&[]);
let labels_or = filters.labels_or.as_deref().unwrap_or(&[]);
if !(labels_and.is_empty() && labels_or.is_empty()) {
match self.label_filter_candidate_ids(labels_and, labels_or)? {
Some(issue_ids) if issue_ids.is_empty() => return Ok(0),
Some(issue_ids) => {
append_issue_id_membership_filter(&mut sql, &mut params, &issue_ids);
}
None => {}
}
}
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({})", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({})", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
if !filters.include_templates {
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
}
if let Some(ref title_contains) = filters.title_contains {
sql.push_str(" AND title LIKE ? ESCAPE '\\'");
let escaped = escape_like_pattern(title_contains);
params.push(SqliteValue::from(format!("%{escaped}%")));
}
if let Some(ts) = filters.updated_before {
sql.push_str(" AND updated_at <= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
if let Some(ts) = filters.updated_after {
sql.push_str(" AND updated_at >= ?");
params.push(SqliteValue::from(ts.to_rfc3339()));
}
sql.push_str(" AND ");
sql.push_str(SEARCH_COUNT_NEEDLE_PREDICATE);
let needle = trimmed.to_ascii_lowercase();
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle.as_str()));
params.push(SqliteValue::from(needle));
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let count = rows
.first()
.and_then(|row| row.get(0))
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
fn search_default_visible_limited_page(
&self,
query: &str,
include_deferred: bool,
limit: usize,
projection: SearchIssueProjection,
) -> Result<Vec<Issue>> {
let status_filter = if include_deferred {
"status NOT IN ('closed', 'tombstone')"
} else {
"status NOT IN ('closed', 'tombstone', 'deferred')"
};
let needle = query.to_ascii_lowercase();
if !self.search_default_visible_has_match(status_filter, &needle)? {
return Ok(Vec::new());
}
let mut issues = self.search_default_visible_priority_window(
status_filter,
&needle,
projection,
"priority = ?",
Priority::CRITICAL.0,
"created_at DESC, id ASC",
limit,
)?;
let remaining = limit.saturating_sub(issues.len());
if remaining > 0 {
issues.extend(self.search_default_visible_priority_window(
status_filter,
&needle,
projection,
"priority > ?",
Priority::CRITICAL.0,
"priority ASC, created_at DESC, id ASC",
remaining,
)?);
}
Ok(issues)
}
fn search_default_visible_has_match(&self, status_filter: &str, needle: &str) -> Result<bool> {
let sql = format!(
"SELECT 1 FROM issues
WHERE {status_filter}
AND (is_template = 0 OR is_template IS NULL)
AND {SEARCH_NEEDLE_PREDICATE}
LIMIT 1"
);
let rows = self.conn.query_with_params(
&sql,
&[
SqliteValue::from(needle),
SqliteValue::from(needle),
SqliteValue::from(needle),
SqliteValue::from(needle),
],
)?;
Ok(!rows.is_empty())
}
#[allow(clippy::too_many_arguments)]
fn search_default_visible_priority_window(
&self,
status_filter: &str,
needle: &str,
projection: SearchIssueProjection,
priority_predicate: &str,
priority_value: i32,
order_by: &str,
limit: usize,
) -> Result<Vec<Issue>> {
let sql = format!(
r"{}
AND {status_filter}
AND (is_template = 0 OR is_template IS NULL)
AND {priority_predicate}
AND {SEARCH_NEEDLE_PREDICATE}
ORDER BY {order_by}
LIMIT {limit}",
projection.select_clause()
);
let rows = self.conn.query_with_params(
&sql,
&[
SqliteValue::from(i64::from(priority_value)),
SqliteValue::from(needle),
SqliteValue::from(needle),
SqliteValue::from(needle),
SqliteValue::from(needle),
],
)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(projection.parse_issue(row)?);
}
Ok(issues)
}
/// Get ready issues (configured ready status, unblocked, not time-deferred,
/// not pinned, not ephemeral).
///
/// Ready definition:
/// 1. Status belongs to the configured ready group (`open` by default), with
/// `deferred` added when `include_deferred` is set
/// 2. NOT in `blocked_issues_cache`
/// 3. `defer_until` is NULL or <= now (unless `include_deferred`)
/// 4. `pinned = 0` (not pinned)
/// 5. `ephemeral = 0` AND ID does not contain `-wisp-`
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::too_many_lines)]
pub fn get_ready_issues(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
) -> Result<Vec<Issue>> {
self.get_ready_issues_with_projection(filters, sort, ReadyIssueProjection::Full)
}
/// Get ready issues optimized for `ready` command rendering.
///
/// Hydrates only the columns consumed by the command's JSON/TOON/text
/// output, avoiding large overflow-page reads for fields that never reach
/// the user-facing result.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_ready_issues_for_command_output(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
) -> Result<Vec<Issue>> {
self.get_ready_issues_with_projection(filters, sort, ReadyIssueProjection::Command)
}
/// Get ready issues optimized for compact text command rendering.
///
/// Hydrates only the columns read by ready text/table output and ordering.
/// Structured JSON/TOON callers should use
/// [`Self::get_ready_issues_for_command_output`] to preserve the existing
/// schema.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_ready_summary_issues_for_command_output(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
) -> Result<Vec<Issue>> {
self.get_ready_issues_with_projection(filters, sort, ReadyIssueProjection::Summary)
}
fn get_ready_issues_with_projection(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
projection: ReadyIssueProjection,
) -> Result<Vec<Issue>> {
let readiness = self.ready_readiness_probe(filters)?;
if !readiness.has_candidate_status {
return Ok(Vec::new());
}
// Resolve `--parent` membership in Rust so the candidate query filters
// on a plain `id IN (...)` list rather than an `IN (subquery)` /
// recursive CTE (see `ReadyFilters::parent_member_ids`). Skip the work
// when membership has already been resolved by the caller.
let resolved_filters;
let filters = if filters.parent.is_some() && filters.parent_member_ids.is_none() {
let mut owned = filters.clone();
owned.parent_member_ids = Some(self.resolve_ready_parent_member_ids(
owned.parent.as_deref().unwrap_or_default(),
owned.recursive,
)?);
resolved_filters = owned;
&resolved_filters
} else {
filters
};
if ready_parent_membership_exceeds_sql_parameter_limit(filters, sort) {
return self.query_ready_issues_for_oversized_parent_membership(
filters,
sort,
projection,
readiness.blocked_cache_stale,
);
}
// Read-only path: if the cache is stale, compute blocked IDs in memory
// instead of persisting (issue #216 — read ops must not write).
if readiness.blocked_cache_stale {
let blocked_ids = match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => map.into_keys().collect(),
Err(error) => self.recover_blocked_ids("ready_issues_stale", &error)?,
};
return self.query_ready_issues_without_cache_with_projection(
filters,
sort,
&blocked_ids,
projection,
);
}
match self
.query_ready_issue_candidates_with_projection(filters, sort, true, true, projection)
{
Ok(issues) => Ok(issues),
Err(error) => {
let blocked_ids = self.recover_blocked_ids("ready_issues_query", &error)?;
self.query_ready_issues_without_cache_with_projection(
filters,
sort,
&blocked_ids,
projection,
)
}
}
}
/// Resolve the set of issue IDs matched by a `--parent` filter on `ready`.
///
/// Returns the direct children of `parent_id`, or — when `recursive` — all
/// transitive parent-child descendants. Traversal is an iterative BFS with a
/// visited-set, so it terminates in bounded time even if the parent-child
/// graph contains a cycle (the embedded SQLite backend mishandles recursive
/// CTEs referenced from a correlated `EXISTS`, which previously surfaced as a
/// hang/error — #308).
fn resolve_ready_parent_member_ids(
&self,
parent_id: &str,
recursive: bool,
) -> Result<Vec<String>> {
let children_by_parent = Self::load_local_parent_child_edges_impl(&self.conn)?;
let mut members: Vec<String> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
visited.insert(parent_id.to_string());
if recursive {
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(parent_id.to_string());
while let Some(current) = queue.pop_front() {
if let Some(children) = children_by_parent.get(¤t) {
for child in children {
if visited.insert(child.clone()) {
members.push(child.clone());
queue.push_back(child.clone());
}
}
}
}
} else if let Some(children) = children_by_parent.get(parent_id) {
for child in children {
if visited.insert(child.clone()) {
members.push(child.clone());
}
}
}
Ok(members)
}
/// Query an oversized parent-membership set through multiple bounded SQL
/// statements. SQLite counts bound variables across the whole statement,
/// so splitting one `IN` predicate into `OR`-connected chunks does not make
/// more variables legal. Separate statements do; their results are merged,
/// deduplicated, globally sorted, and limited afterwards.
fn query_ready_issues_for_oversized_parent_membership(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
projection: ReadyIssueProjection,
blocked_cache_stale: bool,
) -> Result<Vec<Issue>> {
tracing::debug!(
parent_member_count = filters.parent_member_ids.as_ref().map_or(0, Vec::len),
"Querying oversized ready parent membership in bounded chunks"
);
if blocked_cache_stale {
let blocked_ids = match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => map.into_keys().collect(),
Err(error) => self.recover_blocked_ids("ready_parent_chunks_stale", &error)?,
};
return self.query_ready_parent_membership_chunks(
filters,
sort,
projection,
false,
Some(&blocked_ids),
);
}
match self.query_ready_parent_membership_chunks(filters, sort, projection, true, None) {
Ok(issues) => Ok(issues),
Err(error) => {
let blocked_ids = self.recover_blocked_ids("ready_parent_chunks_query", &error)?;
self.query_ready_parent_membership_chunks(
filters,
sort,
projection,
false,
Some(&blocked_ids),
)
}
}
}
fn query_ready_parent_membership_chunks(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
projection: ReadyIssueProjection,
exclude_blocked_in_sql: bool,
blocked_ids: Option<&HashSet<String>>,
) -> Result<Vec<Issue>> {
let parent_member_ids = filters.parent_member_ids.as_deref().unwrap_or_default();
let chunk_size = ready_parent_membership_sql_capacity(filters).max(1);
let mut issues = Vec::new();
for member_chunk in parent_member_ids.chunks(chunk_size) {
let mut chunk_filters = filters.clone();
chunk_filters.parent_member_ids = Some(member_chunk.to_vec());
chunk_filters.limit = None;
let mut chunk_issues = self.query_ready_issue_candidates_with_projection(
&chunk_filters,
sort,
exclude_blocked_in_sql,
false,
projection,
)?;
if let Some(blocked_ids) = blocked_ids {
chunk_issues.retain(|issue| !blocked_ids.contains(&issue.id));
}
issues.extend(chunk_issues);
}
let mut seen_ids = HashSet::with_capacity(issues.len());
issues.retain(|issue| seen_ids.insert(issue.id.clone()));
sort_ready_issues(&mut issues, sort);
if let Some(limit) = filters.limit
&& limit > 0
&& issues.len() > limit
{
issues.truncate(limit);
}
Ok(issues)
}
#[allow(clippy::too_many_lines)]
fn build_ready_issue_candidates_query(
filters: &ReadyFilters,
sort: ReadySortPolicy,
exclude_blocked_in_sql: bool,
apply_limit: bool,
projection: ReadyIssueProjection,
apply_ordering: bool,
) -> (String, Vec<SqliteValue>) {
let mut sql = String::from(projection.select_clause());
let mut params: Vec<SqliteValue> = Vec::new();
let label_filters_can_use_uncorrelated_in =
filters.types.as_ref().is_none_or(Vec::is_empty)
&& filters.priorities.as_ref().is_none_or(Vec::is_empty)
&& filters.assignee.is_none()
&& filters.parent.is_none();
if label_filters_can_use_uncorrelated_in {
sql.push_str(" FROM issues WHERE 1=1");
append_label_membership_filters(
&mut sql,
&mut params,
&filters.labels_and,
&filters.labels_or,
);
} else {
append_issue_source_with_label_and_joins(&mut sql, &mut params, &filters.labels_and);
sql.push_str(" WHERE 1=1");
append_label_or_membership_exists(&mut sql, &mut params, &filters.labels_or);
}
// Ready condition 1: the configured ready status group is "ready"
// (issue #354). The default group is `[open]`, matching pre-#354
// behavior (in_progress means already claimed). `--include-deferred`
// additionally folds in `deferred` without double-counting it if the
// configured group already lists it.
// The status list is inlined as SQL string literals rather than bound
// `?` params. Status values are internal, validated, lowercased policy
// names (never raw user input reaching SQL), and the embedded fsqlite
// planner mishandles a bound `IN (?)` predicate when it sits alongside a
// correlated/grouped `id IN (SELECT ... HAVING ...)` label subquery —
// the same engine class of limitation documented on
// `ReadyFilters::parent_member_ids` (#307/#308). Inlining keeps the
// single-`open` case byte-identical to the pre-#354 literal and keeps
// widened groups index-coverable. Single quotes are still escaped
// defensively in case a project configures an exotic custom status.
let _ = write!(
sql,
" AND status IN ({})",
ready_status_sql_literals(filters)
);
// Ready condition 2: blocked issues are filtered in SQL when the cache
// is healthy; fallback callers filter them in Rust after directly
// recomputing the blocker graph from dependencies.
if exclude_blocked_in_sql {
sql.push_str(" AND issues.id NOT IN (SELECT issue_id FROM blocked_issues_cache)");
}
// Ready condition 3: `defer_until` is NULL or <= now (unless `include_deferred`)
if !filters.include_deferred {
sql.push_str(" AND (defer_until IS NULL OR datetime(defer_until) <= datetime('now'))");
}
// Ready condition 4: not pinned. Legacy rows may still store NULL,
// which the rest of the storage layer treats as false.
sql.push_str(" AND (pinned = 0 OR pinned IS NULL)");
// Ready condition 5: not ephemeral and not wisp. Legacy rows may
// still store NULL, which should behave the same as false.
sql.push_str(" AND (ephemeral = 0 OR ephemeral IS NULL)");
sql.push_str(" AND id NOT LIKE '%-wisp-%'");
// Exclude templates
sql.push_str(" AND (is_template = 0 OR is_template IS NULL)");
// Filter by types
if let Some(ref types) = filters.types
&& !types.is_empty()
{
let placeholders: Vec<String> = types.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND issue_type IN ({}) ", placeholders.join(","));
for t in types {
params.push(SqliteValue::from(t.as_str()));
}
}
// Filter by priorities
if let Some(ref priorities) = filters.priorities
&& !priorities.is_empty()
{
let placeholders: Vec<String> = priorities.iter().map(|_| "?".to_string()).collect();
let _ = write!(sql, " AND priority IN ({})", placeholders.join(","));
for p in priorities {
params.push(SqliteValue::from(i64::from(p.0)));
}
}
// Filter by assignee
if let Some(ref assignee) = filters.assignee {
sql.push_str(" AND assignee = ?");
params.push(SqliteValue::from(assignee.as_str()));
}
// Filter for unassigned
if filters.unassigned {
sql.push_str(" AND (assignee IS NULL OR assignee = '')");
}
// Filter by parent (--parent flag).
//
// Membership is resolved in Rust (see `resolve_ready_parent_member_ids`)
// and passed as `parent_member_ids`, so we filter with a plain
// `id IN (...)` list rather than an `IN (subquery)` / recursive CTE. This
// avoids embedded-SQLite planner bugs around `IN (subquery)` under
// multi-table joins (#307) and recursive CTEs referenced from a
// correlated `EXISTS` (#308).
let cte_prefix = String::new();
if filters.parent.is_some() {
match filters.parent_member_ids.as_deref() {
Some([]) | None => {
// Parent has no matching descendants (or membership was not
// resolved): force an empty result without scanning.
sql.push_str(" AND 1=0");
}
Some(member_ids) => {
append_issue_id_membership_filter(&mut sql, &mut params, member_ids);
}
}
}
if apply_ordering {
match sort {
ReadySortPolicy::Hybrid => {
sql.push_str(
" ORDER BY CASE WHEN issues.priority <= 1 THEN 0 ELSE 1 END, issues.created_at ASC, issues.id ASC",
);
}
ReadySortPolicy::Priority => {
sql.push_str(
" ORDER BY issues.priority ASC, issues.created_at ASC, issues.id ASC",
);
}
ReadySortPolicy::Oldest => {
sql.push_str(" ORDER BY issues.created_at ASC, issues.id ASC");
}
}
}
// Apply limit in SQL only when the blocked filter also happens in SQL.
// Fallback callers must filter blocked IDs in Rust first.
if apply_limit
&& let Some(limit) = filters.limit
&& limit > 0
{
let _ = write!(sql, " LIMIT {limit}");
}
if !cte_prefix.is_empty() {
sql = format!("{cte_prefix}{sql}");
}
(sql, params)
}
fn query_ready_issue_candidates_with_projection(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
exclude_blocked_in_sql: bool,
apply_limit: bool,
projection: ReadyIssueProjection,
) -> Result<Vec<Issue>> {
if sort == ReadySortPolicy::Hybrid
&& apply_limit
&& let Some(issues) = self.query_limited_ready_hybrid_high_bucket(
filters,
exclude_blocked_in_sql,
projection,
)?
{
return Ok(issues);
}
let sort_hybrid_in_rust = sort == ReadySortPolicy::Hybrid && filters.limit.is_none();
let (sql, params) = Self::build_ready_issue_candidates_query(
filters,
sort,
exclude_blocked_in_sql,
apply_limit,
projection,
!sort_hybrid_in_rust,
);
let rows = self.conn.query_with_params(&sql, ¶ms)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(projection.parse_row(row)?);
}
if sort_hybrid_in_rust {
sort_ready_hybrid(&mut issues);
}
Ok(issues)
}
fn query_limited_ready_hybrid_high_bucket(
&self,
filters: &ReadyFilters,
exclude_blocked_in_sql: bool,
projection: ReadyIssueProjection,
) -> Result<Option<Vec<Issue>>> {
let Some(limit) = filters.limit.filter(|limit| *limit > 0) else {
return Ok(None);
};
let priorities = ready_hybrid_high_bucket_priorities(filters.priorities.as_deref());
if priorities.is_empty() {
return Ok(None);
}
let mut high_bucket_filters = filters.clone();
high_bucket_filters.priorities = Some(priorities);
high_bucket_filters.limit = Some(limit);
if exclude_blocked_in_sql {
let issues = self.query_ready_issue_candidates_with_projection(
&high_bucket_filters,
ReadySortPolicy::Oldest,
true,
true,
projection,
)?;
return Ok((issues.len() >= limit).then_some(issues));
}
let mut summary_issues = self.query_ready_issue_candidates_with_projection(
&high_bucket_filters,
ReadySortPolicy::Oldest,
false,
false,
ReadyIssueProjection::Summary,
)?;
if exclude_blocked_in_sql {
let blocked_ids = self.get_blocked_ids()?;
summary_issues.retain(|issue| !blocked_ids.contains(issue.id.as_str()));
}
if summary_issues.len() >= limit {
summary_issues.truncate(limit);
if projection == ReadyIssueProjection::Summary {
return Ok(Some(summary_issues));
}
let ids: Vec<String> = summary_issues.into_iter().map(|issue| issue.id).collect();
Ok(Some(self.get_ready_issues_by_ids_with_projection(
&ids, projection,
)?))
} else {
Ok(None)
}
}
fn get_ready_issues_by_ids_with_projection(
&self,
ids: &[String],
projection: ReadyIssueProjection,
) -> Result<Vec<Issue>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let mut by_id = HashMap::with_capacity(ids.len());
for chunk in ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"{} FROM issues WHERE id IN ({})",
projection.select_clause(),
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let issue = projection.parse_row(row)?;
by_id.insert(issue.id.clone(), issue);
}
}
let mut issues = Vec::with_capacity(ids.len());
for id in ids {
if let Some(issue) = by_id.remove(id) {
issues.push(issue);
}
}
Ok(issues)
}
fn query_ready_issues_without_cache_with_projection(
&self,
filters: &ReadyFilters,
sort: ReadySortPolicy,
blocked_ids: &HashSet<String>,
projection: ReadyIssueProjection,
) -> Result<Vec<Issue>> {
let mut issues = self.query_ready_issue_candidates_with_projection(
filters, sort, false, false, projection,
)?;
issues.retain(|issue| !blocked_ids.contains(issue.id.as_str()));
if let Some(limit) = filters.limit
&& limit > 0
&& issues.len() > limit
{
issues.truncate(limit);
}
Ok(issues)
}
fn recover_blocked_issues_map(
&self,
stage: &'static str,
error: &dyn std::fmt::Display,
) -> Result<HashMap<String, Vec<String>>> {
if is_transient_wal_tail_read_error(error) {
tracing::trace!(
stage,
%error,
"Blocked cache unavailable during transient WAL tail read; computing blocker graph directly"
);
} else {
tracing::warn!(
stage,
%error,
"Blocked cache unavailable; computing blocker graph directly"
);
}
Self::compute_blocked_issues_map_impl(&self.conn)
}
fn recover_blocked_ids(
&self,
stage: &'static str,
error: &dyn std::fmt::Display,
) -> Result<HashSet<String>> {
Ok(self
.recover_blocked_issues_map(stage, error)?
.into_keys()
.collect())
}
/// Get IDs of blocked issues from cache.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocked_ids(&self) -> Result<HashSet<String>> {
// Read-only path: if the cache is stale, compute in memory instead of
// persisting (issue #216 — read ops must not write).
if self.blocked_cache_marked_stale()? {
return match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => Ok(map.into_keys().collect()),
Err(error) => self.recover_blocked_ids("get_blocked_ids_stale", &error),
};
}
let rows = match self.conn.query("SELECT issue_id FROM blocked_issues_cache") {
Ok(rows) => rows,
Err(error) => return self.recover_blocked_ids("get_blocked_ids_query", &error),
};
let mut ids = HashSet::new();
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
ids.insert(id.to_string());
}
}
Ok(ids)
}
/// Get raw `blocks` dependency edges as (issue_id, depends_on_id) pairs.
///
/// This is a lightweight single-table query (no JOINs) suitable for
/// callers that already have issues loaded in memory and can filter
/// by status themselves.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocks_dep_edges(&self) -> Result<Vec<(String, String)>> {
let mut edges = Vec::new();
// Query 1: Standard blocking types
let rows1 = self.conn.query(
"SELECT issue_id, depends_on_id FROM dependencies \
WHERE type IN ('blocks', 'conditional-blocks', 'waits-for')",
)?;
for row in &rows1 {
if let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text)
&& let Some(depends_on) = row.get(1).and_then(SqliteValue::as_text)
{
edges.push((issue_id.to_string(), depends_on.to_string()));
}
}
// Query 2: Parent-child (reversed direction)
let rows2 = self.conn.query(
"SELECT depends_on_id, issue_id FROM dependencies \
WHERE type = 'parent-child'",
)?;
for row in &rows2 {
if let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text)
&& let Some(depends_on) = row.get(1).and_then(SqliteValue::as_text)
{
edges.push((issue_id.to_string(), depends_on.to_string()));
}
}
Ok(edges)
}
/// Get raw blocking dependency edges whose endpoints are in `issue_ids`.
///
/// Returns `(issue_id, depends_on_id)` pairs, matching [`Self::get_blocks_dep_edges`].
/// For large active sets, falls back to the full edge scan to stay below
/// SQLite's parameter limit.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocks_dep_edges_for_issue_ids(
&self,
issue_ids: &[&str],
) -> Result<Vec<(String, String)>> {
if issue_ids.is_empty() {
return Ok(Vec::new());
}
if issue_ids.len() > BLOCKS_DEP_EDGE_FILTER_LIMIT {
return self.get_blocks_dep_edges();
}
let mut edges = Vec::new();
let placeholders: Vec<&str> = issue_ids.iter().map(|_| "?").collect();
let placeholders = placeholders.join(", ");
let mut params = Vec::with_capacity(issue_ids.len() * 2);
for issue_id in issue_ids {
params.push(SqliteValue::from(*issue_id));
}
for issue_id in issue_ids {
params.push(SqliteValue::from(*issue_id));
}
let standard_sql = format!(
"SELECT issue_id, depends_on_id FROM dependencies \
WHERE type IN ('blocks', 'conditional-blocks', 'waits-for') \
AND issue_id IN ({placeholders}) \
AND depends_on_id IN ({placeholders})"
);
let rows1 = self.conn.query_with_params(&standard_sql, ¶ms)?;
for row in &rows1 {
if let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text)
&& let Some(depends_on) = row.get(1).and_then(SqliteValue::as_text)
{
edges.push((issue_id.to_string(), depends_on.to_string()));
}
}
let parent_child_sql = format!(
"SELECT depends_on_id, issue_id FROM dependencies \
WHERE type = 'parent-child' \
AND depends_on_id IN ({placeholders}) \
AND issue_id IN ({placeholders})"
);
let rows2 = self.conn.query_with_params(&parent_child_sql, ¶ms)?;
for row in &rows2 {
if let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text)
&& let Some(depends_on) = row.get(1).and_then(SqliteValue::as_text)
{
edges.push((issue_id.to_string(), depends_on.to_string()));
}
}
Ok(edges)
}
/// Check if an issue is blocked (in the blocked cache).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn is_blocked(&self, issue_id: &str) -> Result<bool> {
// Read-only path: if the cache is stale, compute in memory instead of
// persisting (issue #216 — read ops must not write).
if self.blocked_cache_marked_stale()? {
let blocked_ids = match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => map.into_keys().collect::<HashSet<_>>(),
Err(error) => self.recover_blocked_ids("is_blocked_stale", &error)?,
};
return Ok(blocked_ids.contains(issue_id));
}
let rows = match self.conn.query_with_params(
"SELECT 1 FROM blocked_issues_cache WHERE issue_id = ? LIMIT 1",
&[SqliteValue::from(issue_id)],
) {
Ok(rows) => rows,
Err(error) => {
let blocked_ids = self.recover_blocked_ids("is_blocked_query", &error)?;
return Ok(blocked_ids.contains(issue_id));
}
};
Ok(!rows.is_empty())
}
/// Get the actual blockers for an issue from the blocked issues cache.
///
/// Returns the issue IDs that are blocking this issue. The format includes
/// status annotations like "bd-123:open" or "bd-456:parent-blocked".
/// Returns an empty vec if the issue is not blocked.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blockers(&self, issue_id: &str) -> Result<Vec<String>> {
Ok(Self::blocker_refs_to_issue_ids(
&self.get_blocker_refs(issue_id)?,
))
}
/// Get the blockers that prevent *starting* work on an issue (claiming it or
/// moving it to `in_progress`).
///
/// This is [`get_blockers`](Self::get_blockers) minus two *rollup* markers
/// that are not real prerequisite edges on the issue itself:
///
/// - `:child-open` — a *close-ordering* constraint (an epic should not be
/// *closed* while it still has open children). It must NOT prevent the
/// epic from being claimed and worked on (#315).
/// - `:parent-blocked` — propagated down onto a child from an
/// *already-blocked* parent epic. `parent-child` is hierarchy, not a
/// prerequisite edge from the parent to the child, so a child that has no
/// real blocker of its own must remain claimable even while its parent
/// epic is blocked (#357 — the start-path counterpart of #355, which
/// stripped the same marker on the *close* path). The actionable children
/// of a blocked epic are frequently exactly the work that unblocks it.
///
/// Real start-blocking edges on the child itself (`blocks`,
/// `conditional-blocks`, `waits-for`) do not carry either suffix and are
/// retained — a child with a *direct* prerequisite still cannot be started.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_start_blockers(&self, issue_id: &str) -> Result<Vec<String>> {
let start_blocker_refs: Vec<String> = self
.get_blocker_refs(issue_id)?
.into_iter()
.filter(|blocker| {
!blocker.ends_with(CHILD_OPEN_BLOCKER_SUFFIX)
&& !blocker.ends_with(PARENT_BLOCKED_SUFFIX)
})
.collect();
Ok(Self::blocker_refs_to_issue_ids(&start_blocker_refs))
}
/// Get the blockers that prevent *closing* an issue.
///
/// This is [`get_blockers`](Self::get_blockers) minus the
/// `:parent-blocked` rollup markers. Those markers encode a *readiness*
/// constraint propagated from an already-blocked parent epic down onto its
/// children — a child of a blocked epic is not "ready" to be *started* —
/// but `parent-child` is hierarchy, not a prerequisite edge from the parent
/// to the child. A finished child must be closable even while its parent
/// epic remains blocked or open (#355). Real prerequisite edges on the
/// child itself (`blocks`, `conditional-blocks`, `waits-for`) and the
/// `:child-open` close-ordering rollup (so an epic still can't close over
/// open children) are retained.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_close_blockers(&self, issue_id: &str) -> Result<Vec<String>> {
let close_blocker_refs: Vec<String> = self
.get_blocker_refs(issue_id)?
.into_iter()
.filter(|blocker| !blocker.ends_with(PARENT_BLOCKED_SUFFIX))
.collect();
Ok(Self::blocker_refs_to_issue_ids(&close_blocker_refs))
}
/// Return the raw, annotation-bearing blocker refs for an issue (e.g.
/// `bd-123:open`, `bd-456:parent-blocked`, `bd-789:child-open`), or an empty
/// vec if the issue is not blocked. Callers that only need issue IDs should
/// use [`get_blockers`](Self::get_blockers); callers enforcing start/claim
/// ordering should use [`get_start_blockers`](Self::get_start_blockers).
fn get_blocker_refs(&self, issue_id: &str) -> Result<Vec<String>> {
// Read-only path: if the cache is stale, compute in memory instead of
// persisting (issue #216 — read ops must not write).
if self.blocked_cache_marked_stale()? {
let blocked_issues_map = match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => map,
Err(error) => self.recover_blocked_issues_map("get_blockers_stale", &error)?,
};
return Ok(blocked_issues_map
.get(issue_id)
.map_or_else(Vec::new, Clone::clone));
}
let rows = match self.conn.query_with_params(
"SELECT blocked_by FROM blocked_issues_cache WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
) {
Ok(rows) => rows,
Err(error) => {
let blocked_issues_map =
self.recover_blocked_issues_map("get_blockers_query", &error)?;
return Ok(blocked_issues_map
.get(issue_id)
.map_or_else(Vec::new, Clone::clone));
}
};
let Some(row) = rows.first() else {
return Ok(Vec::new());
};
match parse_blocked_by_json(issue_id, row.get(0).and_then(SqliteValue::as_text)) {
Ok(blockers) => Ok(blockers),
Err(error) => {
let blocked_issues_map =
self.recover_blocked_issues_map("get_blockers_parse", &error)?;
Ok(blocked_issues_map
.get(issue_id)
.map_or_else(Vec::new, Clone::clone))
}
}
}
/// Rebuild the blocked issues cache from scratch.
///
/// This computes which issues are blocked based on their dependencies
/// and the status of their blockers. Standard blocking edges (`blocks`,
/// `conditional-blocks`, `waits-for`) block directly. `parent-child`
/// does not make an open parent block a child; instead it propagates an
/// already-blocked parent down to its descendants.
///
/// # Errors
///
/// Returns an error if the database operation fails.
#[allow(clippy::too_many_lines)]
pub fn rebuild_blocked_cache(&mut self, force_rebuild: bool) -> Result<usize> {
if !force_rebuild {
return Ok(0);
}
// Disable FK enforcement before the transaction (#215).
self.conn.execute("PRAGMA foreign_keys = OFF")?;
let result = self.with_write_transaction(|storage| {
let rebuilt = Self::rebuild_blocked_cache_impl(&storage.conn)?;
Self::upsert_metadata_key_in_tx(
&storage.conn,
BLOCKED_CACHE_STATE_KEY,
METADATA_EMPTY_VALUE,
)?;
Ok(rebuilt)
});
Self::finish_foreign_key_suppressed_result(&self.conn, "blocked-cache rebuild", result)
}
/// Rebuild the blocked cache using the caller's active transaction.
///
/// Assumes FK enforcement has already been disabled by the caller's
/// transaction wrapper.
///
/// # Errors
///
/// Returns an error if the rebuild fails.
pub(crate) fn rebuild_blocked_cache_in_tx(&self) -> Result<usize> {
let rebuilt = Self::rebuild_blocked_cache_impl(&self.conn)?;
Self::upsert_metadata_key_in_tx(&self.conn, BLOCKED_CACHE_STATE_KEY, METADATA_EMPTY_VALUE)?;
Ok(rebuilt)
}
/// Rebuild the blocked cache and normalize its operational timestamps to
/// the exact source-snapshot time bound into a reviewed recovery plan.
pub(crate) fn rebuild_blocked_cache_at_in_tx(&self, blocked_at: &str) -> Result<usize> {
let rebuilt = self.rebuild_blocked_cache_in_tx()?;
self.conn.execute_with_params(
"UPDATE blocked_issues_cache SET blocked_at = ?",
&[SqliteValue::from(blocked_at)],
)?;
Ok(rebuilt)
}
/// Rebuild the child counters table from all existing issues.
///
/// Useful after a full import or manual database manipulation.
///
/// # Errors
///
/// Returns an error if the rebuild fails.
pub(crate) fn rebuild_child_counters_in_tx(&self) -> Result<usize> {
Self::rebuild_child_counters_impl(&self.conn)
}
#[allow(dead_code)] // Guarded standalone entry point; bulk mutations use the in-tx primitive.
pub(crate) fn rebuild_child_counters(&self) -> Result<usize> {
self.with_connection_write_transaction(|_| self.rebuild_child_counters_in_tx())
}
fn rebuild_child_counters_impl(conn: &Connection) -> Result<usize> {
// Clear existing counters
conn.execute("DELETE FROM child_counters")?;
// Build counters only for parents that still exist. Recovered imports can
// contain hierarchical IDs whose root parent was deleted long ago.
let rows = conn.query("SELECT id FROM issues")?;
let issue_ids: HashSet<String> = rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
let mut max_children: HashMap<String, u32> = HashMap::new();
let mut skipped_missing_parents = 0usize;
for id in &issue_ids {
let Ok(parsed) = parse_id(id) else {
continue;
};
if parsed.is_root() {
continue;
}
let Some(parent) = parsed.parent() else {
skipped_missing_parents += 1;
continue;
};
if !issue_ids.contains(&parent) {
skipped_missing_parents += 1;
continue;
}
let Some(&child_num) = parsed.child_path.last() else {
skipped_missing_parents += 1;
continue;
};
let entry = max_children.entry(parent).or_insert(0);
if child_num > *entry {
*entry = child_num;
}
}
if skipped_missing_parents > 0 {
tracing::debug!(
skipped_missing_parents,
"Skipped child counter rebuild for hierarchical issues whose parent ID is missing"
);
}
let mut count = 0;
for (parent_id, last_child) in max_children {
// Explicit DELETE + INSERT instead of INSERT OR REPLACE because
// fsqlite does not reliably support UNIQUE constraint upserts.
conn.execute_with_params(
"DELETE FROM child_counters WHERE parent_id = ?",
&[SqliteValue::from(parent_id.as_str())],
)?;
conn.execute_with_params(
"INSERT INTO child_counters (parent_id, last_child) VALUES (?, ?)",
&[
SqliteValue::from(parent_id.as_str()),
SqliteValue::from(i64::from(last_child)),
],
)?;
count += 1;
}
Ok(count)
}
fn compute_blocked_issues_map_impl(conn: &Connection) -> Result<HashMap<String, Vec<String>>> {
let mut blocked_issues_map = Self::load_direct_blockers_impl(conn)?;
let children_by_parent = Self::load_local_parent_child_edges_impl(conn)?;
// 1. Propagate standard blockers (blocks, conditional-blocks, waits-for)
// from parent to children.
Self::propagate_blocked_parents(&mut blocked_issues_map, &children_by_parent);
// 2. Add blockers for parents with open children.
// We do this AFTER propagation so that a parent blocked only by its children
// does not transitively block those same children (avoiding logic cycle).
let child_blockers = Self::load_local_open_child_blockers_impl(conn)?;
for (parent_id, mut blockers) in child_blockers {
blocked_issues_map
.entry(parent_id)
.or_default()
.append(&mut blockers);
}
blocked_issues_map.retain(|_, blockers| {
blockers.sort();
blockers.dedup();
!blockers.is_empty()
});
Ok(blocked_issues_map)
}
pub(crate) fn blocked_cache_projection_health(
conn: &Connection,
) -> BlockedCacheProjectionHealth {
let direct_map = Self::compute_blocked_issues_map_impl(conn).ok();
let cached_map = Self::load_blocked_cache_projection_map(conn).ok();
Self::compare_blocked_cache_projection(cached_map.as_ref(), direct_map.as_ref())
}
pub(crate) fn ready_projection_health(conn: &Connection) -> ReadyProjectionHealth {
let cached_ready_ids = Self::query_ready_projection_ids(conn, None, true).ok();
let direct_blocked_ids = Self::compute_blocked_issues_map_impl(conn)
.ok()
.map(|map| map.into_keys().collect::<HashSet<_>>());
let direct_ready_ids = direct_blocked_ids.as_ref().and_then(|blocked_ids| {
Self::query_ready_projection_ids(conn, Some(blocked_ids), false).ok()
});
Self::compare_ready_projection(cached_ready_ids.as_ref(), direct_ready_ids.as_ref())
}
fn compare_blocked_cache_projection(
cached: Option<&HashMap<String, Vec<String>>>,
direct: Option<&HashMap<String, Vec<String>>>,
) -> BlockedCacheProjectionHealth {
let direct_blocked_rows = direct.map(HashMap::len);
let (Some(cached), Some(direct)) = (cached, direct) else {
return BlockedCacheProjectionHealth::unavailable(direct_blocked_rows);
};
let missing_rows = direct.keys().filter(|id| !cached.contains_key(*id)).count();
let extra_rows = cached.keys().filter(|id| !direct.contains_key(*id)).count();
let mismatched_rows = direct
.iter()
.filter(|(id, blockers)| {
cached
.get(id.as_str())
.is_some_and(|cached_blockers| cached_blockers != *blockers)
})
.count();
let parity_status = if missing_rows == 0 && extra_rows == 0 && mismatched_rows == 0 {
"matches"
} else {
"mismatch"
};
BlockedCacheProjectionHealth {
parity_status: parity_status.to_string(),
direct_blocked_rows,
cached_missing_rows: Some(missing_rows),
cached_extra_rows: Some(extra_rows),
cached_mismatched_rows: Some(mismatched_rows),
}
}
fn load_blocked_cache_projection_map(
conn: &Connection,
) -> Result<HashMap<String, Vec<String>>> {
let rows = conn.query("SELECT issue_id, blocked_by FROM blocked_issues_cache")?;
let mut cached_map = HashMap::new();
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let mut blockers =
parse_blocked_by_json(issue_id, row.get(1).and_then(SqliteValue::as_text))?;
blockers.sort();
blockers.dedup();
cached_map.insert(issue_id.to_string(), blockers);
}
Ok(cached_map)
}
fn compare_ready_projection(
cached: Option<&HashSet<String>>,
direct: Option<&HashSet<String>>,
) -> ReadyProjectionHealth {
let cached_ready_rows = cached.map(HashSet::len);
let direct_ready_rows = direct.map(HashSet::len);
let (Some(cached), Some(direct)) = (cached, direct) else {
return ReadyProjectionHealth::unavailable(cached_ready_rows, direct_ready_rows);
};
let missing_rows = direct.difference(cached).count();
let extra_rows = cached.difference(direct).count();
let parity_status = if missing_rows == 0 && extra_rows == 0 {
"matches"
} else {
"mismatch"
};
ReadyProjectionHealth {
parity_status: parity_status.to_string(),
cached_ready_rows,
direct_ready_rows,
cached_ready_missing_rows: Some(missing_rows),
cached_ready_extra_rows: Some(extra_rows),
}
}
fn query_ready_projection_ids(
conn: &Connection,
direct_blocked_ids: Option<&HashSet<String>>,
exclude_blocked_in_sql: bool,
) -> Result<HashSet<String>> {
let (sql, params) = Self::build_ready_issue_candidates_query(
&ReadyFilters::default(),
ReadySortPolicy::Priority,
exclude_blocked_in_sql,
false,
ReadyIssueProjection::Command,
false,
);
let rows = conn.query_with_params(&sql, ¶ms)?;
let mut ready_ids = HashSet::with_capacity(rows.len());
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
if direct_blocked_ids.is_some_and(|blocked_ids| blocked_ids.contains(issue_id)) {
continue;
}
ready_ids.insert(issue_id.to_string());
}
Ok(ready_ids)
}
fn blocker_refs_to_issue_ids(blockers: &[String]) -> Vec<String> {
blockers
.iter()
.map(|blocker| {
blocker
.split(':')
.next()
.unwrap_or(blocker.as_str())
.to_string()
})
.collect()
}
fn load_blocked_issues_from_map_with_projection(
&self,
blocked_issues_map: &HashMap<String, Vec<String>>,
projection: BlockedIssueProjection,
) -> Result<Vec<(Issue, Vec<String>)>> {
if blocked_issues_map.is_empty() {
return Ok(Vec::new());
}
let sql = format!(
"{} FROM issues
WHERE status NOT IN ('closed', 'tombstone')
ORDER BY priority ASC, created_at DESC, id ASC",
projection.map_select_clause()
);
let rows = self.conn.query(&sql)?;
let mut blocked_issues = Vec::new();
for row in &rows {
let issue = projection.parse_issue(row)?;
if let Some(blockers) = blocked_issues_map.get(issue.id.as_str()) {
blocked_issues.push((issue, blockers.clone()));
}
}
Ok(blocked_issues)
}
fn rebuild_blocked_cache_impl(conn: &Connection) -> Result<usize> {
let blocked_issues_map = Self::compute_blocked_issues_map_impl(conn)?;
// Clear the cache table before repopulating with fresh entries.
Self::reset_blocked_cache_table(conn)?;
let mut entries = Vec::with_capacity(blocked_issues_map.len());
for (issue_id, blockers) in blocked_issues_map {
let blockers_json = match serde_json::to_string(&blockers) {
Ok(blockers_json) => blockers_json,
Err(error) => {
tracing::warn!(
issue_id = %issue_id,
%error,
"Failed to serialize blocker list; treating issue as unblocked"
);
continue;
}
};
entries.push((issue_id, blockers_json));
}
let count = Self::insert_blocked_cache_entries(conn, &entries)?;
tracing::debug!(blocked_count = count, "Rebuilt blocked issues cache");
Ok(count)
}
fn reset_blocked_cache_table(conn: &Connection) -> Result<()> {
// Use DELETE FROM instead of DROP TABLE + CREATE TABLE to avoid
// frankensqlite page leak (#224): DROP TABLE leaves old root pages
// unreachable, causing integrity_check to report 'Page N: never used'.
//
// The table and index are guaranteed to exist (created at schema apply
// time via SCHEMA_SQL). Per-entry DELETE+INSERT in
// insert_blocked_cache_entries handles any phantom B-tree entries that
// fsqlite may retain after bulk DELETE (#215).
if table_exists(conn, "blocked_issues_cache") {
conn.execute("DELETE FROM blocked_issues_cache")?;
} else {
// Table doesn't exist yet (fresh DB before schema fully applied,
// or recovery scenario). Fall back to CREATE.
execute_batch(
conn,
r"
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
);
CREATE INDEX IF NOT EXISTS idx_blocked_cache_blocked_at
ON blocked_issues_cache(blocked_at);
",
)?;
}
Ok(())
}
/// Incremental blocked-cache update: recompute only the entries for the
/// given seed issue IDs and their connected parent-child components.
///
/// This avoids the full DELETE + INSERT cycle of `rebuild_blocked_cache_impl`
/// when only a small number of dependency edges changed.
fn incremental_blocked_cache_update(
conn: &Connection,
seed_ids: &HashSet<String>,
) -> Result<usize> {
let children_by_parent = Self::load_local_parent_child_edges_impl(conn)?;
let parents_by_child = Self::build_parents_by_child(&children_by_parent);
let affected =
Self::expand_blocked_cache_component(seed_ids, &children_by_parent, &parents_by_child);
let affected_children_by_parent =
Self::filter_parent_child_edges_for_ids(&children_by_parent, &affected);
// Recompute only the affected component instead of rebuilding the full
// blocker graph inside the active write transaction.
let mut blocked_issues_map = Self::load_direct_blockers_for_ids_impl(conn, &affected)?;
Self::propagate_blocked_parents(&mut blocked_issues_map, &affected_children_by_parent);
let child_blockers = Self::load_local_open_child_blockers_for_ids_impl(conn, &affected)?;
for (parent_id, mut blockers) in child_blockers {
blocked_issues_map
.entry(parent_id)
.or_default()
.append(&mut blockers);
}
// 3. Delete only affected rows from the cache (batched).
let affected_vec: Vec<_> = affected.iter().collect();
for chunk in affected_vec.chunks(BLOCKED_CACHE_DELETE_CHUNK_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"DELETE FROM blocked_issues_cache WHERE issue_id IN ({})",
placeholders.join(", ")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
conn.execute_with_params(&sql, ¶ms)?;
}
// 4. Re-insert only affected rows that have blockers.
let mut entries = Vec::new();
for id in &affected {
if let Some(mut blockers) = blocked_issues_map.remove(id.as_str()) {
if blockers.is_empty() {
continue;
}
blockers.sort();
blockers.dedup();
let blockers_json = match serde_json::to_string(&blockers) {
Ok(json) => json,
Err(error) => {
tracing::warn!(
issue_id = %id,
%error,
"Failed to serialize blocker list; treating issue as unblocked"
);
continue;
}
};
entries.push((id.clone(), blockers_json));
}
}
let count = Self::insert_blocked_cache_entries(conn, &entries)?;
tracing::debug!(
affected_count = affected.len(),
blocked_count = count,
"Incremental blocked cache update"
);
Ok(count)
}
fn insert_blocked_cache_entries(
conn: &Connection,
entries: &[(String, String)],
) -> Result<usize> {
// Callers are responsible for disabling FK enforcement before calling
// this function. fsqlite can surface false FK violations when its page
// buffer pool is exhausted (#215).
let mut count = 0;
for (issue_id, blockers_json) in entries {
// Explicit DELETE + INSERT instead of INSERT OR REPLACE because
// fsqlite does not reliably support UNIQUE constraint upserts.
conn.execute_with_params(
"DELETE FROM blocked_issues_cache WHERE issue_id = ?",
&[SqliteValue::from(issue_id.as_str())],
)?;
conn.execute_with_params(
"INSERT INTO blocked_issues_cache (issue_id, blocked_by, blocked_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(blockers_json.as_str()),
],
)?;
count += 1;
}
Ok(count)
}
fn load_direct_blockers_impl(conn: &Connection) -> Result<HashMap<String, Vec<String>>> {
// Exclude external dependencies from the persisted cache because their
// status is not locally known and must be resolved at query time.
let rows = conn.query(
"SELECT DISTINCT d.issue_id, d.depends_on_id || ':' || COALESCE(i.status, 'unknown')
FROM dependencies d
LEFT JOIN issues i ON d.depends_on_id = i.id
WHERE d.type IN ('blocks', 'conditional-blocks', 'waits-for')
AND d.depends_on_id NOT LIKE 'external:%'
AND (
i.status NOT IN ('closed', 'tombstone')
OR i.id IS NULL
)
AND (i.is_template = 0 OR i.is_template IS NULL OR i.id IS NULL)",
)?;
let mut blocked_issues_map: HashMap<String, Vec<String>> = HashMap::new();
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(blocker_ref) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if issue_id.is_empty() || blocker_ref.is_empty() {
continue;
}
blocked_issues_map
.entry(issue_id.to_string())
.or_default()
.push(blocker_ref.to_string());
}
Ok(blocked_issues_map)
}
fn load_direct_blockers_for_ids_impl(
conn: &Connection,
issue_ids: &HashSet<String>,
) -> Result<HashMap<String, Vec<String>>> {
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut blocked_issues_map: HashMap<String, Vec<String>> = HashMap::new();
let issue_ids: Vec<_> = issue_ids.iter().collect();
for chunk in issue_ids.chunks(400) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT DISTINCT d.issue_id, d.depends_on_id || ':' || COALESCE(i.status, 'unknown')
FROM dependencies d
LEFT JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id IN ({})
AND d.type IN ('blocks', 'conditional-blocks', 'waits-for')
AND d.depends_on_id NOT LIKE 'external:%'
AND (
i.status NOT IN ('closed', 'tombstone')
OR i.id IS NULL
)
AND (i.is_template = 0 OR i.is_template IS NULL OR i.id IS NULL)",
placeholders.join(", ")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect();
let rows = conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(blocker_ref) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if issue_id.is_empty() || blocker_ref.is_empty() {
continue;
}
blocked_issues_map
.entry(issue_id.to_string())
.or_default()
.push(blocker_ref.to_string());
}
}
Ok(blocked_issues_map)
}
fn load_local_parent_child_edges_impl(
conn: &Connection,
) -> Result<HashMap<String, Vec<String>>> {
let edge_rows = conn.query(
"SELECT issue_id, depends_on_id
FROM dependencies
WHERE type = 'parent-child'
AND issue_id NOT LIKE 'external:%'
AND depends_on_id NOT LIKE 'external:%'",
)?;
let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
for row in &edge_rows {
let Some(child_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(parent_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
children_by_parent
.entry(parent_id.to_string())
.or_default()
.push(child_id.to_string());
}
Ok(children_by_parent)
}
fn build_parents_by_child(
children_by_parent: &HashMap<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
let mut parents_by_child: HashMap<String, Vec<String>> = HashMap::new();
for (parent_id, children) in children_by_parent {
for child_id in children {
parents_by_child
.entry(child_id.clone())
.or_default()
.push(parent_id.clone());
}
}
parents_by_child
}
fn expand_blocked_cache_component(
seed_ids: &HashSet<String>,
children_by_parent: &HashMap<String, Vec<String>>,
parents_by_child: &HashMap<String, Vec<String>>,
) -> HashSet<String> {
let mut affected = seed_ids.clone();
let mut queue: Vec<String> = seed_ids.iter().cloned().collect();
while let Some(id) = queue.pop() {
if let Some(children) = children_by_parent.get(&id) {
for child_id in children {
if affected.insert(child_id.clone()) {
queue.push(child_id.clone());
}
}
}
if let Some(parents) = parents_by_child.get(&id) {
for parent_id in parents {
if affected.insert(parent_id.clone()) {
queue.push(parent_id.clone());
}
}
}
}
affected
}
fn filter_parent_child_edges_for_ids(
children_by_parent: &HashMap<String, Vec<String>>,
issue_ids: &HashSet<String>,
) -> HashMap<String, Vec<String>> {
let mut filtered = HashMap::new();
for (parent_id, children) in children_by_parent {
if !issue_ids.contains(parent_id) {
continue;
}
let affected_children: Vec<String> = children
.iter()
.filter(|child_id| issue_ids.contains(child_id.as_str()))
.cloned()
.collect();
if !affected_children.is_empty() {
filtered.insert(parent_id.clone(), affected_children);
}
}
filtered
}
fn load_local_open_child_blockers_impl(
conn: &Connection,
) -> Result<HashMap<String, Vec<String>>> {
// Parents are treated as "blocked by open children" ONLY when the
// parent is an epic. For epics that is the natural semantics: the
// epic aggregates its children and cannot itself be closed (or
// meaningfully worked on) while any child is still open. For plain
// task/feature/bug/etc. parents in a parent-child chain, the
// tests (`parent_child_transitive_blocking`,
// `deep_parent_child_chain_blocking`,
// `deep_chain_beyond_50_levels_blocks_all_descendants`) and the
// `conformance.rs` docstring describe the opposite direction:
// children inherit a parent's *blocked* state via
// `propagate_blocked_parents`, but an open, unblocked non-epic
// parent does not itself become blocked just because it has open
// children. Restricting this rule to `p.issue_type = 'epic'`
// preserves the epic-rollup behaviour while avoiding the
// every-parent-with-open-kids false-blocked that was masking
// genuine unblock transitions along a parent-child chain.
//
// Join on the parent issue (p) to guarantee `depends_on_id` exists
// in the `issues` table. The `dependencies.depends_on_id` column
// has no foreign key (intentionally, for external refs), so
// dangling rows can accumulate. Without this guard the subsequent
// INSERT into `blocked_issues_cache` (which *does* have a FK on
// `issue_id`) fails with "FOREIGN KEY constraint failed" (#215).
let rows = conn.query(&format!(
"SELECT DISTINCT d.depends_on_id as parent_id, d.issue_id || '{CHILD_OPEN_BLOCKER_SUFFIX}' as blocker
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
JOIN issues p ON d.depends_on_id = p.id
WHERE d.type = 'parent-child'
AND p.issue_type = 'epic'
AND i.status NOT IN ('closed', 'tombstone')
AND (i.is_template = 0 OR i.is_template IS NULL)
AND d.depends_on_id NOT LIKE 'external:%'
AND d.issue_id NOT LIKE 'external:%'",
))?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in &rows {
let Some(parent_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(blocker) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if parent_id.is_empty() || blocker.is_empty() {
continue;
}
map.entry(parent_id.to_string())
.or_default()
.push(blocker.to_string());
}
Ok(map)
}
fn load_local_open_child_blockers_for_ids_impl(
conn: &Connection,
parent_ids: &HashSet<String>,
) -> Result<HashMap<String, Vec<String>>> {
if parent_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, Vec<String>> = HashMap::new();
let parent_ids: Vec<_> = parent_ids.iter().collect();
for chunk in parent_ids.chunks(400) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
// Join on the parent issue (p) to guarantee depends_on_id exists
// in the issues table — same guard as the non-ids variant (#215).
// Epic-only scoping matches the non-ids variant: the "parent
// blocked by open children" rollup is epic-specific, not a
// property of every parent-child edge.
let sql = format!(
"SELECT DISTINCT d.depends_on_id as parent_id, d.issue_id || '{CHILD_OPEN_BLOCKER_SUFFIX}' as blocker
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
JOIN issues p ON d.depends_on_id = p.id
WHERE d.depends_on_id IN ({})
AND d.type = 'parent-child'
AND p.issue_type = 'epic'
AND i.status NOT IN ('closed', 'tombstone')
AND (i.is_template = 0 OR i.is_template IS NULL)
AND d.depends_on_id NOT LIKE 'external:%'
AND d.issue_id NOT LIKE 'external:%'",
placeholders.join(", ")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|parent_id| SqliteValue::from(parent_id.as_str()))
.collect();
let rows = conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(parent_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(blocker) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if parent_id.is_empty() || blocker.is_empty() {
continue;
}
map.entry(parent_id.to_string())
.or_default()
.push(blocker.to_string());
}
}
Ok(map)
}
fn propagate_blocked_parents(
blocked_issues_map: &mut HashMap<String, Vec<String>>,
children_by_parent: &HashMap<String, Vec<String>>,
) {
if children_by_parent.is_empty() || blocked_issues_map.is_empty() {
return;
}
let mut queue: Vec<String> = blocked_issues_map.keys().cloned().collect();
let mut seen: HashSet<String> = HashSet::new();
while let Some(parent_id) = queue.pop() {
if !seen.insert(parent_id.clone()) {
continue;
}
if let Some(children) = children_by_parent.get(&parent_id) {
for child_id in children {
let marker = format!("{parent_id}:parent-blocked");
let entry = blocked_issues_map.entry(child_id.clone()).or_default();
if entry.contains(&marker) {
continue;
}
entry.push(marker);
queue.push(child_id.clone());
}
}
}
}
/// Get issues that are blocked, along with what's blocking them.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocked_issues(&self) -> Result<Vec<(Issue, Vec<String>)>> {
self.get_blocked_issues_with_projection(BlockedIssueProjection::Full)
}
/// Get blocked issues optimized for `blocked` command rendering.
///
/// Hydrates only the issue columns consumed by blocked JSON/TOON/text
/// output, avoiding large overflow-page reads for fields that never reach
/// the user-facing result.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocked_issues_for_command_output(&self) -> Result<Vec<(Issue, Vec<String>)>> {
self.get_blocked_issues_with_projection(BlockedIssueProjection::Command)
}
fn get_blocked_issues_with_projection(
&self,
projection: BlockedIssueProjection,
) -> Result<Vec<(Issue, Vec<String>)>> {
// Read-only path: if the cache is stale, compute in memory instead of
// persisting (issue #216 — read ops must not write).
if self.blocked_cache_marked_stale()? {
let blocked_issues_map = match Self::compute_blocked_issues_map_impl(&self.conn) {
Ok(map) => map,
Err(error) => {
self.recover_blocked_issues_map("get_blocked_issues_stale", &error)?
}
};
return self
.load_blocked_issues_from_map_with_projection(&blocked_issues_map, projection);
}
let sql = format!(
"{} FROM issues i
INNER JOIN blocked_issues_cache bc ON i.id = bc.issue_id
WHERE i.status NOT IN ('closed', 'tombstone')
ORDER BY i.priority ASC, i.created_at DESC, i.id ASC",
projection.cached_select_clause()
);
let rows = match self.conn.query(&sql) {
Ok(rows) => rows,
Err(error) => {
let blocked_issues_map =
self.recover_blocked_issues_map("get_blocked_issues_query", &error)?;
return self
.load_blocked_issues_from_map_with_projection(&blocked_issues_map, projection);
}
};
let mut blocked_issues = Vec::new();
for row in &rows {
let issue = projection.parse_issue(row)?;
let blockers = match parse_blocked_by_json(
&issue.id,
row.get(projection.cached_blocked_by_index())
.and_then(SqliteValue::as_text),
) {
Ok(blockers) => blockers,
Err(error) => {
let blocked_issues_map =
self.recover_blocked_issues_map("get_blocked_issues_parse", &error)?;
return self.load_blocked_issues_from_map_with_projection(
&blocked_issues_map,
projection,
);
}
};
blocked_issues.push((issue, blockers));
}
Ok(blocked_issues)
}
/// Return true unless the blocked command can safely emit an empty result.
///
/// The false result is deliberately narrow: the blocked cache must be fresh,
/// there must be no cached local blockers, and there must be no external
/// blocking dependencies that could create command-only blocked rows.
///
/// # Errors
///
/// Returns an error if the blocked-cache stale marker cannot be read.
pub fn may_have_blocked_command_results(&self) -> Result<bool> {
if self.blocked_cache_marked_stale()? {
return Ok(true);
}
let rows = match self.conn.query(
"SELECT
EXISTS(SELECT 1 FROM blocked_issues_cache LIMIT 1),
EXISTS(
SELECT 1
FROM dependencies INDEXED BY idx_dependencies_depends_on
WHERE depends_on_id >= 'external:'
AND depends_on_id < 'external;'
AND type IN ('blocks', 'conditional-blocks', 'waits-for')
LIMIT 1
),
EXISTS(
SELECT 1
FROM dependencies d INDEXED BY idx_dependencies_issue
JOIN issues p ON d.depends_on_id = p.id
WHERE d.issue_id >= 'external:'
AND d.issue_id < 'external;'
AND d.type = 'parent-child'
AND p.issue_type = 'epic'
LIMIT 1
)",
) {
Ok(rows) => rows,
Err(error) => {
tracing::debug!(
%error,
"Blocked command candidate probe failed; falling back to full query"
);
return Ok(true);
}
};
let Some(row) = rows.first() else {
return Ok(true);
};
Ok(
row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) != 0
|| row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0) != 0
|| row.get(2).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
)
}
/// Check if the project has any external dependencies.
///
/// # Errors
///
/// Returns an error if the dependency probe query fails.
pub fn has_external_dependencies(&self, blocking_only: bool) -> Result<bool> {
// `external;` is the next ASCII boundary after the `external:` prefix,
// making the range equivalent to LIKE 'external:%' under SQLite's
// default binary TEXT collation while staying index-friendly.
let target_sql = if blocking_only {
"SELECT 1
FROM dependencies INDEXED BY idx_dependencies_depends_on
WHERE depends_on_id >= 'external:'
AND depends_on_id < 'external;'
AND type IN ('blocks', 'conditional-blocks', 'waits-for')
LIMIT 1"
} else {
"SELECT 1
FROM dependencies INDEXED BY idx_dependencies_depends_on
WHERE depends_on_id >= 'external:'
AND depends_on_id < 'external;'
LIMIT 1"
};
let rows = self.conn.query(target_sql)?;
if !rows.is_empty() {
return Ok(true);
}
let parent_sql = if blocking_only {
// CASE is lazy: when the indexed external-child range is empty,
// fsqlite does not execute the disproportionately expensive epic
// join. Keeping both branches in one statement avoids paying a
// second query setup cost when an external child does exist.
"SELECT 1
WHERE CASE
WHEN EXISTS (
SELECT 1
FROM dependencies INDEXED BY idx_dependencies_issue
WHERE issue_id >= 'external:'
AND issue_id < 'external;'
AND type = 'parent-child'
LIMIT 1
)
THEN EXISTS (
SELECT 1
FROM dependencies d INDEXED BY idx_dependencies_issue
JOIN issues p ON d.depends_on_id = p.id
WHERE d.issue_id >= 'external:'
AND d.issue_id < 'external;'
AND d.type = 'parent-child'
AND p.issue_type = 'epic'
LIMIT 1
)
ELSE 0
END
LIMIT 1"
} else {
"SELECT 1
FROM dependencies INDEXED BY idx_dependencies_issue
WHERE issue_id >= 'external:'
AND issue_id < 'external;'
AND type = 'parent-child'
LIMIT 1"
};
let rows = self.conn.query(parent_sql)?;
Ok(!rows.is_empty())
}
/// Resolve external dependency satisfaction for dependencies of this project.
///
/// Returns a map of external dependency IDs to whether they are satisfied.
/// Missing projects or query failures are treated as unsatisfied.
///
/// # Errors
///
/// Returns an error if querying local dependencies fails.
pub fn resolve_external_dependency_statuses(
&self,
external_db_paths: &HashMap<String, PathBuf>,
blocking_only: bool,
) -> Result<HashMap<String, bool>> {
let external_ids = self.list_external_dependency_ids(blocking_only)?;
Ok(Self::resolve_external_dependency_statuses_for_ids(
&external_ids,
external_db_paths,
))
}
pub(crate) fn resolve_external_dependency_statuses_for_ids(
external_ids: &HashSet<String>,
external_db_paths: &HashMap<String, PathBuf>,
) -> HashMap<String, bool> {
if external_ids.is_empty() {
return HashMap::new();
}
let mut project_caps: HashMap<String, HashSet<String>> = HashMap::new();
let mut parsed: HashMap<String, (String, String)> = HashMap::new();
for dep_id in external_ids {
if let Some((project, capability)) = parse_external_dependency(dep_id) {
project_caps
.entry(project.clone())
.or_default()
.insert(capability.clone());
parsed.insert(dep_id.clone(), (project, capability));
}
}
// Query each external project's database to find satisfied capabilities
let mut satisfied: HashMap<String, HashSet<String>> = HashMap::new();
for (project, caps) in &project_caps {
let Some(db_path) = external_db_paths.get(project) else {
tracing::warn!(
project = %project,
"External project not configured; treating dependencies as unsatisfied"
);
continue;
};
match query_external_project_capabilities(db_path, caps) {
Ok(found) => {
satisfied.insert(project.clone(), found);
}
Err(err) => {
tracing::warn!(
project = %project,
path = %db_path.display(),
error = %err,
"Failed to query external project; treating dependencies as unsatisfied"
);
}
}
}
let mut statuses = HashMap::new();
for dep_id in external_ids {
let is_satisfied = parsed
.get(dep_id.as_str())
.is_some_and(|(project, capability)| {
satisfied
.get(project)
.is_some_and(|caps| caps.contains(capability))
});
statuses.insert(dep_id.clone(), is_satisfied);
}
statuses
}
/// Compute blockers caused by unsatisfied external dependencies.
///
/// This excludes external dependencies from the blocked cache and evaluates
/// them at query time, including parent-child propagation.
///
/// # Errors
///
/// Returns an error if dependency queries fail.
pub fn external_blockers(
&self,
external_statuses: &HashMap<String, bool>,
) -> Result<HashMap<String, Vec<String>>> {
let mut blockers: HashMap<String, Vec<String>> = HashMap::new();
// Direct external blockers.
// 1. Local issues blocked by external targets (standard blocking types)
let rows = self.conn.query(
"SELECT issue_id, depends_on_id
FROM dependencies
WHERE depends_on_id LIKE 'external:%'
AND type IN ('blocks', 'conditional-blocks', 'waits-for')",
)?;
for row in &rows {
let issue_id = row.get(0).and_then(SqliteValue::as_text).unwrap_or("");
let depends_on_id = row.get(1).and_then(SqliteValue::as_text).unwrap_or("");
let satisfied = external_statuses
.get(depends_on_id)
.copied()
.unwrap_or(false);
if !satisfied {
blockers
.entry(issue_id.to_string())
.or_default()
.push(format!("{depends_on_id}:blocked"));
}
}
// 2. Local epic parents blocked by external children. This mirrors
// `load_local_open_child_blockers_impl`: child-open rollup is an epic
// aggregation rule, not a property of every parent-child edge.
let rows = self.conn.query(
"SELECT d.depends_on_id, d.issue_id
FROM dependencies d
JOIN issues p ON d.depends_on_id = p.id
WHERE d.issue_id LIKE 'external:%'
AND d.type = 'parent-child'
AND p.issue_type = 'epic'",
)?;
for row in &rows {
let parent_id = row.get(0).and_then(SqliteValue::as_text).unwrap_or("");
let child_id = row.get(1).and_then(SqliteValue::as_text).unwrap_or("");
let satisfied = external_statuses.get(child_id).copied().unwrap_or(false);
if !satisfied {
blockers
.entry(parent_id.to_string())
.or_default()
.push(format!("{child_id}:child-blocked"));
}
}
// Propagate externally blocked parents down through local parent-child relationships.
let edge_rows = self.conn.query(
"SELECT issue_id, depends_on_id
FROM dependencies
WHERE type = 'parent-child'
AND issue_id NOT LIKE 'external:%'
AND depends_on_id NOT LIKE 'external:%'",
)?;
let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
for row in &edge_rows {
let Some(child_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(parent_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
children_by_parent
.entry(parent_id.to_string())
.or_default()
.push(child_id.to_string());
}
if !children_by_parent.is_empty() && !blockers.is_empty() {
let mut queue: Vec<String> = blockers.keys().cloned().collect();
let mut seen: HashSet<String> = HashSet::new();
while let Some(parent_id) = queue.pop() {
if !seen.insert(parent_id.clone()) {
continue;
}
if let Some(children) = children_by_parent.get(&parent_id) {
for child_id in children {
let entry = blockers.entry(child_id.clone()).or_default();
let marker = format!("{parent_id}:parent-blocked");
if entry.contains(&marker) {
continue;
}
entry.push(marker);
queue.push(child_id.clone());
}
}
}
}
for refs in blockers.values_mut() {
refs.sort();
refs.dedup();
}
Ok(blockers)
}
fn list_external_dependency_ids(&self, blocking_only: bool) -> Result<HashSet<String>> {
let mut ids = HashSet::new();
let sql = if blocking_only {
"SELECT DISTINCT depends_on_id
FROM dependencies
WHERE depends_on_id LIKE 'external:%'
AND type IN ('blocks', 'conditional-blocks', 'waits-for')
UNION
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues p ON d.depends_on_id = p.id
WHERE d.issue_id LIKE 'external:%'
AND d.type = 'parent-child'
AND p.issue_type = 'epic'"
} else {
"SELECT DISTINCT depends_on_id
FROM dependencies
WHERE depends_on_id LIKE 'external:%'
UNION
SELECT DISTINCT issue_id
FROM dependencies
WHERE issue_id LIKE 'external:%'"
};
let rows = self.conn.query(sql)?;
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
ids.insert(id.to_string());
}
}
Ok(ids)
}
/// Check if an issue ID already exists.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn id_exists(&self, id: &str) -> Result<bool> {
Ok(Self::get_issue_from_conn(&self.conn, id)?.is_some())
}
/// Find issue IDs with a title that exactly matches `title`.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn find_ids_by_exact_title(&self, title: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
"SELECT id FROM issues WHERE title = ? ORDER BY created_at ASC, id ASC",
&[SqliteValue::from(title.trim())],
)?;
Ok(rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
fn issue_status_in_tx(conn: &Connection, id: &str) -> Result<Option<Status>> {
Ok(Self::get_issue_from_conn(conn, id)?.map(|issue| issue.status))
}
fn ensure_issue_mutable_in_tx(conn: &Connection, issue_id: &str, action: &str) -> Result<()> {
match Self::issue_status_in_tx(conn, issue_id)? {
Some(Status::Tombstone) => Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot {action} tombstone issue: {issue_id}"),
}),
Some(_) => Ok(()),
None => Err(BeadsError::IssueNotFound {
id: issue_id.to_string(),
}),
}
}
fn ensure_dependency_target_exists_in_tx(conn: &Connection, depends_on_id: &str) -> Result<()> {
if depends_on_id.starts_with("external:") {
return Ok(());
}
match Self::issue_status_in_tx(conn, depends_on_id)? {
Some(Status::Tombstone) => Err(BeadsError::Validation {
field: "depends_on_id".to_string(),
reason: format!("cannot depend on tombstone issue: {depends_on_id}"),
}),
Some(_) => Ok(()),
None => Err(BeadsError::IssueNotFound {
id: depends_on_id.to_string(),
}),
}
}
fn validate_parent_child_endpoints(
issue_id: &str,
depends_on_id: &str,
dep_type: &str,
) -> Result<()> {
if dep_type.eq_ignore_ascii_case("parent-child")
&& (issue_id.starts_with("external:") || depends_on_id.starts_with("external:"))
{
let (field, endpoint) = if issue_id.starts_with("external:") {
("issue_id", issue_id)
} else {
("depends_on_id", depends_on_id)
};
return Err(BeadsError::Validation {
field: field.to_string(),
reason: format!("parent-child dependencies must link local issues: {endpoint}"),
});
}
Ok(())
}
fn canonical_standard_dependency_type(dep_type: &str) -> Option<&'static str> {
match dep_type.to_ascii_lowercase().as_str() {
"blocks" => Some("blocks"),
"parent-child" => Some("parent-child"),
"conditional-blocks" => Some("conditional-blocks"),
"waits-for" => Some("waits-for"),
"related" => Some("related"),
"discovered-from" => Some("discovered-from"),
"replies-to" => Some("replies-to"),
"relates-to" => Some("relates-to"),
"duplicates" => Some("duplicates"),
"supersedes" => Some("supersedes"),
"caused-by" => Some("caused-by"),
_ => None,
}
}
fn validate_new_parent_child_parent_in_tx(
conn: &Connection,
issue_id: &str,
depends_on_id: &str,
) -> Result<bool> {
let existing_parent = conn
.query_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type COLLATE NOCASE = 'parent-child' ORDER BY rowid ASC LIMIT 1",
&[SqliteValue::from(issue_id)],
)?
.first()
.and_then(|row| row.get(0).and_then(SqliteValue::as_text))
.map(str::to_string);
match existing_parent {
Some(existing_parent) if existing_parent == depends_on_id => Ok(false),
Some(existing_parent) => Err(BeadsError::Validation {
field: "depends_on_id".to_string(),
reason: format!(
"issue {issue_id} already has parent {existing_parent}; clear or replace the existing parent before adding {depends_on_id}"
),
}),
None => Ok(true),
}
}
fn existing_dependency_targets_for_issue_ids(
conn: &Connection,
issue_ids: &[&str],
) -> Result<HashMap<String, HashSet<String>>> {
let mut targets_by_issue_id: HashMap<String, HashSet<String>> = HashMap::new();
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT issue_id, depends_on_id FROM dependencies WHERE issue_id IN ({})",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|issue_id| SqliteValue::from(*issue_id))
.collect();
let rows = conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(depends_on_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
targets_by_issue_id
.entry(issue_id.to_string())
.or_default()
.insert(depends_on_id.to_string());
}
}
Ok(targets_by_issue_id)
}
/// Find issue IDs that end with the given hash substring.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn find_ids_by_hash(&self, hash_suffix: &str) -> Result<Vec<String>> {
let all_ids = self.get_all_ids()?;
Ok(crate::util::id::find_matching_ids(&all_ids, hash_suffix))
}
/// Count total issues in the database.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_issues(&self) -> Result<usize> {
let row = self.conn.query_row("SELECT count(*) FROM issues")?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
/// Count the two derived sync tables without rebuilding either one.
///
/// Additive reconciliation uses this read-only snapshot to prove whether
/// its transactional cache rebuild changed either materialized view.
///
/// # Errors
///
/// Returns an error if either count query fails.
pub(crate) fn count_sync_derived_rows(&self) -> Result<(usize, usize)> {
let blocked = self
.conn
.query_row("SELECT count(*) FROM blocked_issues_cache")?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
let child_counters = self
.conn
.query_row("SELECT count(*) FROM child_counters")?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
Ok((
usize::try_from(blocked).unwrap_or(0),
usize::try_from(child_counters).unwrap_or(0),
))
}
/// Get all issue IDs in the database.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_ids(&self) -> Result<Vec<String>> {
let rows = self.conn.query("SELECT id FROM issues ORDER BY id")?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Get epic counts (total children, closed children) for all epics.
///
/// Returns a map from epic ID to (`total_children`, `closed_children`).
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn get_epic_counts(&self) -> Result<std::collections::HashMap<String, (usize, usize)>> {
// Fetch raw rows and aggregate in Rust to avoid SUM(CASE WHEN ... THEN 1 ELSE 0 END)
// which crashes fsqlite (it doesn't support non-column arguments in aggregate functions).
let rows = self.conn.query(
"SELECT
d.depends_on_id AS epic_id,
i.status
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.type = 'parent-child'
AND (i.is_template = 0 OR i.is_template IS NULL)",
)?;
let mut counts: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for row in &rows {
let epic_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let status = row.get(1).and_then(SqliteValue::as_text).unwrap_or("");
let entry = counts.entry(epic_id).or_insert((0, 0));
entry.0 += 1; // total
if status == "closed" || status == "tombstone" {
entry.1 += 1; // closed
}
}
Ok(counts)
}
/// Returns IDs of direct dot-notation children of `parent_id` that are
/// still open or in-progress, ignoring any formally declared parent-child
/// dep rows.
///
/// Rationale: `get_epic_counts()` covers the happy path (issues created
/// with `br create --parent ...`, which writes a `parent-child` row in
/// `dependencies`). But legacy beads DBs, direct database migrations, or
/// older storage versions can contain IDs like
/// `bd-epic.1`, `bd-epic.2` that are semantically children but have no dep
/// row. Without this check, `br close bd-epic` silently closes the parent
/// while leaving those children orphaned.
///
/// Excludes grandchildren (e.g. `bd-epic.1.1` is not a direct child of
/// `bd-epic`). LIKE pattern specials in the parent id are escaped via
/// `escape_like_pattern` + `ESCAPE '\\'` so any id is safe to pass.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_open_dot_notation_children(&self, parent_id: &str) -> Result<Vec<String>> {
let escaped = escape_like_pattern(parent_id);
let direct_prefix = format!("{escaped}.%");
let grandchild_prefix = format!("{escaped}.%.%");
let rows = self.conn.query_with_params(
"SELECT i.id FROM issues i \
WHERE i.status IN ('open', 'in_progress') \
AND (i.is_template = 0 OR i.is_template IS NULL) \
AND i.id LIKE ? ESCAPE '\\' \
AND i.id NOT LIKE ? ESCAPE '\\'",
&[
SqliteValue::from(direct_prefix.as_str()),
SqliteValue::from(grandchild_prefix.as_str()),
],
)?;
let mut result = Vec::with_capacity(rows.len());
for row in &rows {
if let Some(id) = row.get(0).and_then(SqliteValue::as_text) {
result.push(id.to_string());
}
}
Ok(result)
}
/// Add a dependency between issues.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn add_dependency(
&mut self,
issue_id: &str,
depends_on_id: &str,
dep_type: &str,
actor: &str,
) -> Result<bool> {
self.add_dependency_with_metadata(issue_id, depends_on_id, dep_type, actor, None)
}
/// Add a dependency link with optional JSON metadata.
///
/// # Errors
///
/// Returns an error if the dependency is invalid, the metadata is not valid JSON,
/// or the database update fails.
pub fn add_dependency_with_metadata(
&mut self,
issue_id: &str,
depends_on_id: &str,
dep_type: &str,
actor: &str,
metadata: Option<&str>,
) -> Result<bool> {
if issue_id == depends_on_id {
return Err(BeadsError::SelfDependency {
id: issue_id.to_string(),
});
}
// Tolerate a degenerate empty/whitespace-only metadata string the same
// way JSONL deserialization does: treat it as absent rather than
// rejecting it as invalid JSON.
let metadata = match metadata {
Some(metadata) if !metadata.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(metadata).map_err(|err| {
BeadsError::Validation {
field: "metadata".to_string(),
reason: format!(
"dependency metadata must be valid JSON for {issue_id} -> \
{depends_on_id} (type={dep_type}); found {metadata:?}: {err}"
),
}
})?;
metadata
}
_ => "{}",
};
let dep_type = Self::canonical_standard_dependency_type(dep_type).unwrap_or(dep_type);
Self::validate_parent_child_endpoints(issue_id, depends_on_id, dep_type)?;
self.mutate("add_dependency", actor, |conn, ctx| {
match Self::issue_status_in_tx(conn, issue_id)? {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot add dependency from tombstone issue: {issue_id}"),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: issue_id.to_string(),
});
}
}
Self::ensure_dependency_target_exists_in_tx(conn, depends_on_id)?;
if dep_type == "parent-child"
&& !Self::validate_new_parent_child_parent_in_tx(conn, issue_id, depends_on_id)?
{
return Ok(false);
}
let existing = conn.query_with_params(
"SELECT 1 FROM dependencies WHERE issue_id = ? AND depends_on_id = ? LIMIT 1",
&[
SqliteValue::from(issue_id),
SqliteValue::from(depends_on_id),
],
)?;
if !existing.is_empty() {
return Ok(false);
}
// Cycle check runs INSIDE the transaction (BEGIN IMMEDIATE) to
// prevent TOCTOU races where a concurrent writer could insert an
// edge between our check and our INSERT.
if let Ok(dt) = dep_type.parse::<DependencyType>()
&& Self::check_dependency_cycle_for_type(
conn,
issue_id,
depends_on_id,
&dt,
true,
)?
{
return Err(BeadsError::DependencyCycle {
path: format!(
"Adding dependency {issue_id} -> {depends_on_id} would create a cycle"
),
});
}
let inserted = conn.execute_with_params(
"INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata)
VALUES (?, ?, ?, ?, ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(depends_on_id),
SqliteValue::from(dep_type),
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(actor),
SqliteValue::from(metadata),
],
)?;
if inserted == 0 {
return Ok(false);
}
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(
EventType::DependencyAdded,
issue_id,
Some(format!("Added dependency on {depends_on_id} ({dep_type})")),
);
ctx.mark_dirty(issue_id);
// Defer the blocked-cache rebuild to the next read rather than
// doing it eagerly in a second write transaction. This eliminates
// the main DB lock contention source under concurrent agents:
// previously even the incremental path held a second write lock
// while traversing all parent-child edges in the graph.
ctx.invalidate_cache_deferred();
Ok(true)
})
}
/// Add already-resolved import dependencies in one storage mutation.
///
/// This is intentionally narrower than the interactive `dep add` path:
/// callers must have resolved user-facing references and must not attach
/// metadata. The method still validates endpoints, parent-child uniqueness,
/// and the complete proposed blocking graph before inserting anything.
///
/// # Errors
///
/// Returns an error if any dependency is invalid, would create a cycle, or
/// the database update fails.
#[allow(clippy::too_many_lines)]
pub(crate) fn add_dependencies_bulk_for_import(
&mut self,
dependencies: &[BulkDependencyInsert],
actor: &str,
) -> Result<usize> {
if dependencies.is_empty() {
return Ok(0);
}
self.mutate("add_dependencies_bulk_for_import", actor, |conn, ctx| {
let mut unique_dependencies: Vec<(&BulkDependencyInsert, String)> = Vec::new();
let mut seen_edges = HashSet::new();
let mut proposed_parents: HashMap<&str, &str> = HashMap::new();
let mut source_issue_ids: Vec<&str> = dependencies
.iter()
.map(|dep| dep.issue_id.as_str())
.collect();
source_issue_ids.sort_unstable();
source_issue_ids.dedup();
let existing_targets =
Self::existing_dependency_targets_for_issue_ids(conn, &source_issue_ids)?;
for dep in dependencies {
if dep.issue_id == dep.depends_on_id {
return Err(BeadsError::SelfDependency {
id: dep.issue_id.clone(),
});
}
let dep_type = Self::canonical_standard_dependency_type(&dep.dep_type)
.unwrap_or(dep.dep_type.as_str())
.to_string();
Self::validate_parent_child_endpoints(
&dep.issue_id,
&dep.depends_on_id,
&dep_type,
)?;
match Self::issue_status_in_tx(conn, &dep.issue_id)? {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!(
"cannot add dependency from tombstone issue: {}",
dep.issue_id
),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: dep.issue_id.clone(),
});
}
}
Self::ensure_dependency_target_exists_in_tx(conn, &dep.depends_on_id)?;
if existing_targets
.get(dep.issue_id.as_str())
.is_some_and(|targets| targets.contains(dep.depends_on_id.as_str()))
{
continue;
}
if dep_type == "parent-child" {
match proposed_parents.get(dep.issue_id.as_str()) {
Some(existing_parent)
if *existing_parent != dep.depends_on_id.as_str() =>
{
return Err(BeadsError::Validation {
field: "depends_on_id".to_string(),
reason: format!(
"issue {} has multiple imported parents: {existing_parent} and {}",
dep.issue_id, dep.depends_on_id
),
});
}
Some(_) => {}
None => {
proposed_parents
.insert(dep.issue_id.as_str(), dep.depends_on_id.as_str());
}
}
if !Self::validate_new_parent_child_parent_in_tx(
conn,
&dep.issue_id,
&dep.depends_on_id,
)? {
continue;
}
}
if seen_edges.insert((dep.issue_id.clone(), dep.depends_on_id.clone())) {
unique_dependencies.push((dep, dep_type));
}
}
let mut graph = Self::load_dependency_cycle_graph_from_conn(conn)?;
for (dep, dep_type) in &unique_dependencies {
let Ok(parsed_type) = dep_type.parse::<DependencyType>() else {
continue;
};
if !parsed_type.is_blocking() {
continue;
}
let (from, to) = if dep_type == "parent-child" {
(dep.depends_on_id.clone(), dep.issue_id.clone())
} else {
(dep.issue_id.clone(), dep.depends_on_id.clone())
};
graph.entry(to.clone()).or_default();
graph.entry(from).or_default().push(to);
}
for neighbors in graph.values_mut() {
neighbors.sort();
neighbors.dedup();
}
if let Some(cycle) = Self::cycle_witnesses_from_graph(&graph).into_iter().next() {
return Err(BeadsError::DependencyCycle {
path: cycle.join(" -> "),
});
}
let now = Utc::now().to_rfc3339();
let mut inserted_count = 0;
let mut touched_issue_ids = HashSet::new();
for (dep, dep_type) in unique_dependencies {
let inserted = conn.execute_with_params(
"INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata)
VALUES (?, ?, ?, ?, ?, ?)",
&[
SqliteValue::from(dep.issue_id.as_str()),
SqliteValue::from(dep.depends_on_id.as_str()),
SqliteValue::from(dep_type.as_str()),
SqliteValue::from(now.as_str()),
SqliteValue::from(actor),
SqliteValue::from("{}"),
],
)?;
if inserted == 0 {
continue;
}
inserted_count += 1;
touched_issue_ids.insert(dep.issue_id.clone());
ctx.record_event(
EventType::DependencyAdded,
&dep.issue_id,
Some(format!(
"Added dependency on {} ({})",
dep.depends_on_id, dep_type
)),
);
ctx.mark_dirty(&dep.issue_id);
}
for issue_id in &touched_issue_ids {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(now.as_str()),
SqliteValue::from(issue_id.as_str()),
],
)?;
}
if inserted_count > 0 {
ctx.invalidate_cache_deferred();
}
Ok(inserted_count)
})
}
/// Remove a dependency link.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn remove_dependency(
&mut self,
issue_id: &str,
depends_on_id: &str,
actor: &str,
) -> Result<bool> {
self.mutate("remove_dependency", actor, |conn, ctx| {
Self::ensure_issue_mutable_in_tx(conn, issue_id, "remove dependency from")?;
let rows = conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
&[
SqliteValue::from(issue_id),
SqliteValue::from(depends_on_id),
],
)?;
if rows > 0 {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(
EventType::DependencyRemoved,
issue_id,
Some(format!("Removed dependency on {depends_on_id}")),
);
ctx.mark_dirty(issue_id);
// Defer rebuild for the same reason as add_dependency_with_metadata.
ctx.invalidate_cache_deferred();
}
Ok(rows > 0)
})
}
/// Remove all dependencies for an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn remove_all_dependencies(&mut self, issue_id: &str, actor: &str) -> Result<usize> {
self.mutate("remove_all_dependencies", actor, |conn, ctx| {
let affected_rows = conn.query_with_params(
"SELECT DISTINCT issue_id FROM dependencies WHERE depends_on_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let affected: Vec<String> = affected_rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
let outgoing = conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let incoming = conn.execute_with_params(
"DELETE FROM dependencies WHERE depends_on_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let total = outgoing + incoming;
if total > 0 {
let now = Utc::now().to_rfc3339();
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[SqliteValue::from(now.as_str()), SqliteValue::from(issue_id)],
)?;
for chunk in affected.chunks(400) {
for id in chunk {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(now.as_str()),
SqliteValue::from(id.as_str()),
],
)?;
}
}
ctx.record_event(
EventType::DependencyRemoved,
issue_id,
Some(format!("Removed {total} dependency links")),
);
ctx.mark_dirty(issue_id);
for affected_id in &affected {
ctx.mark_dirty(affected_id);
}
let mut cache_ids: Vec<&str> = Vec::with_capacity(affected.len() + 1);
cache_ids.push(issue_id);
cache_ids.extend(affected.iter().map(String::as_str));
ctx.invalidate_cache_for(&cache_ids);
}
Ok(total)
})
}
/// Remove parent-child dependency for an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn remove_parent(&mut self, issue_id: &str, actor: &str) -> Result<bool> {
self.mutate("remove_parent", actor, |conn, ctx| {
Self::ensure_issue_mutable_in_tx(conn, issue_id, "clear parent from")?;
let previous_parent_rows = conn.query_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type = 'parent-child' ORDER BY rowid ASC",
&[SqliteValue::from(issue_id)],
)?;
let previous_parents = previous_parent_rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(str::to_string))
.collect::<Vec<_>>();
let rows = conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ? AND type = 'parent-child'",
&[SqliteValue::from(issue_id)],
)?;
if rows > 0 {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(
EventType::DependencyRemoved,
issue_id,
Some("Removed parent".to_string()),
);
ctx.mark_dirty(issue_id);
let mut cache_ids = vec![issue_id];
for previous_parent in &previous_parents {
let previous_parent = previous_parent.as_str();
if !cache_ids.contains(&previous_parent) {
cache_ids.push(previous_parent);
}
}
ctx.invalidate_cache_for(&cache_ids);
}
Ok(rows > 0)
})
}
/// Set parent for an issue (replace existing).
///
/// # Errors
///
/// Returns an error if the database update fails or cycle detected.
pub fn set_parent(
&mut self,
issue_id: &str,
parent_id: Option<&str>,
actor: &str,
) -> Result<()> {
self.set_parent_with_options(issue_id, parent_id, actor, false)
}
/// Set parent for an issue (replace existing) with optional deferred cache rebuild.
///
/// # Errors
///
/// Returns an error if the database update fails or cycle detected.
pub fn set_parent_with_options(
&mut self,
issue_id: &str,
parent_id: Option<&str>,
actor: &str,
skip_cache_rebuild: bool,
) -> Result<()> {
self.mutate("set_parent", actor, |conn, ctx| {
let action = if parent_id.is_some() {
"set parent on"
} else {
"clear parent from"
};
Self::ensure_issue_mutable_in_tx(conn, issue_id, action)?;
let previous_parent_rows = conn.query_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type = 'parent-child' ORDER BY rowid ASC",
&[SqliteValue::from(issue_id)],
)?;
let previous_parents = previous_parent_rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(str::to_string))
.collect::<Vec<_>>();
if previous_parents.len() == usize::from(parent_id.is_some())
&& previous_parents.first().map(String::as_str) == parent_id
{
return Ok(());
}
// Remove existing parent
conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ? AND type = 'parent-child'",
&[SqliteValue::from(issue_id)],
)?;
if let Some(pid) = parent_id {
if pid == issue_id {
return Err(BeadsError::SelfDependency {
id: issue_id.to_string(),
});
}
Self::validate_parent_child_endpoints(issue_id, pid, "parent-child")?;
Self::ensure_dependency_target_exists_in_tx(conn, pid)?;
if Self::check_parent_child_cycle(conn, issue_id, pid, true)? {
return Err(BeadsError::DependencyCycle {
path: format!("Setting parent of {issue_id} to {pid} would create a cycle"),
});
}
conn.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, ?, 'parent-child', ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(pid),
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(actor),
],
)?;
ctx.record_event(
EventType::DependencyAdded,
issue_id,
Some(format!("Set parent to {pid}")),
);
} else {
ctx.record_event(
EventType::DependencyRemoved,
issue_id,
Some("Removed parent".to_string()),
);
}
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.mark_dirty(issue_id);
if skip_cache_rebuild {
ctx.invalidate_cache_deferred();
} else {
let mut cache_ids = vec![issue_id];
for previous_parent in &previous_parents {
let previous_parent = previous_parent.as_str();
if !cache_ids.contains(&previous_parent) {
cache_ids.push(previous_parent);
}
}
if let Some(parent_id) = parent_id
&& !cache_ids.contains(&parent_id)
{
cache_ids.push(parent_id);
}
ctx.invalidate_cache_for(&cache_ids);
}
Ok(())
})
}
/// Add a label to an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn add_label(&mut self, issue_id: &str, label: &str, actor: &str) -> Result<bool> {
validate_storage_label(label)?;
self.mutate("add_label", actor, |conn, ctx| {
match Self::issue_status_in_tx(conn, issue_id)? {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot add label to tombstone issue: {issue_id}"),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: issue_id.to_string(),
});
}
}
let row = conn.query_row_with_params(
"SELECT count(*) FROM labels WHERE issue_id = ? AND label = ?",
&[SqliteValue::from(issue_id), SqliteValue::from(label)],
)?;
let exists = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
if exists > 0 {
return Ok(false);
}
let row = conn.query_row_with_params(
"SELECT count(*) FROM labels WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let label_count = row
.get(0)
.and_then(SqliteValue::as_integer)
.and_then(|count| usize::try_from(count).ok())
.unwrap_or(usize::MAX);
if label_count >= ISSUE_LABEL_MAX_COUNT {
return Err(label_count_error());
}
conn.execute_with_params(
"INSERT INTO labels (issue_id, label) VALUES (?, ?)",
&[SqliteValue::from(issue_id), SqliteValue::from(label)],
)?;
ctx.record_event(
EventType::LabelAdded,
issue_id,
Some(format!("Added label {label}")),
);
ctx.mark_dirty(issue_id);
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
Ok(true)
})
}
/// Add one label to many issues in a single storage mutation.
///
/// Returns the set of issue IDs that actually gained the label. IDs that
/// already had the label remain idempotent no-ops, matching [`Self::add_label`].
///
/// # Errors
///
/// Returns an error if any target issue is missing, tombstoned, or would
/// exceed the per-issue label limit.
#[allow(clippy::too_many_lines)]
pub fn add_label_to_issues_bulk(
&mut self,
issue_ids: &[String],
label: &str,
actor: &str,
) -> Result<HashSet<String>> {
validate_storage_label(label)?;
if issue_ids.is_empty() {
return Ok(HashSet::new());
}
let unique_issue_ids = dedupe_preserving_order(issue_ids);
self.mutate("add_label_to_issues_bulk", actor, |conn, ctx| {
let mut changed_ids = HashSet::new();
let now_str = Utc::now().to_rfc3339();
// The existing-label probe binds one label plus every issue id, so
// reserve one parameter slot for the label value.
for chunk in unique_issue_ids.chunks(SQLITE_VAR_LIMIT - 1) {
let placeholders = vec!["?"; chunk.len()];
let params = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect::<Vec<_>>();
let rows = conn.query_with_params(
&format!(
"SELECT id, status FROM issues WHERE id IN ({})",
placeholders.join(",")
),
¶ms,
)?;
let mut statuses = HashMap::with_capacity(rows.len());
for row in &rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.ok_or_else(|| {
BeadsError::Config(
"issues row missing id during bulk label add".to_string(),
)
})?
.to_string();
let status = row
.get(1)
.and_then(SqliteValue::as_text)
.ok_or_else(|| {
BeadsError::Config(format!(
"issues row missing status during bulk label add for {id}"
))
})?;
statuses.insert(id, Status::from_str(status)?);
}
for issue_id in chunk {
match statuses.get(issue_id) {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!(
"cannot add label to tombstone issue: {issue_id}"
),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: issue_id.clone(),
});
}
}
}
let mut label_params = Vec::with_capacity(chunk.len() + 1);
label_params.push(SqliteValue::from(label));
label_params.extend(chunk.iter().map(|id| SqliteValue::from(id.as_str())));
let existing_rows = conn.query_with_params(
&format!(
"SELECT issue_id FROM labels WHERE label = ? AND issue_id IN ({})",
placeholders.join(",")
),
&label_params,
)?;
let existing_ids = existing_rows
.iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(ToString::to_string)
})
.collect::<HashSet<_>>();
let missing_ids = chunk
.iter()
.filter(|issue_id| !existing_ids.contains(issue_id.as_str()))
.collect::<Vec<_>>();
if missing_ids.is_empty() {
continue;
}
let missing_placeholders = vec!["?"; missing_ids.len()];
let missing_params = missing_ids
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect::<Vec<_>>();
let count_rows = conn.query_with_params(
&format!(
"SELECT issue_id, COUNT(*) FROM labels WHERE issue_id IN ({}) GROUP BY issue_id",
missing_placeholders.join(",")
),
&missing_params,
)?;
let mut label_counts = HashMap::with_capacity(count_rows.len());
for row in &count_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.ok_or_else(|| {
BeadsError::Config(
"labels row missing issue_id during bulk label add".to_string(),
)
})?
.to_string();
let count = row
.get(1)
.and_then(SqliteValue::as_integer)
.and_then(|count| usize::try_from(count).ok())
.unwrap_or(0);
label_counts.insert(issue_id, count);
}
for issue_id in &missing_ids {
if label_counts
.get(issue_id.as_str())
.copied()
.unwrap_or(0)
>= ISSUE_LABEL_MAX_COUNT
{
return Err(label_count_error());
}
}
for issue_id in &missing_ids {
conn.execute_with_params(
"INSERT INTO labels (issue_id, label) VALUES (?, ?)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from(label),
],
)?;
ctx.record_event(
EventType::LabelAdded,
issue_id,
Some(format!("Added label {label}")),
);
ctx.mark_dirty(issue_id);
changed_ids.insert((*issue_id).clone());
}
for update_chunk in missing_ids.chunks(SQLITE_VAR_LIMIT - 1) {
let update_placeholders = vec!["?"; update_chunk.len()];
let mut update_params = Vec::with_capacity(update_chunk.len() + 1);
update_params.push(SqliteValue::from(now_str.as_str()));
update_params.extend(
update_chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str())),
);
conn.execute_with_params(
&format!(
"UPDATE issues SET updated_at = ? WHERE id IN ({})",
update_placeholders.join(",")
),
&update_params,
)?;
}
}
Ok(changed_ids)
})
}
/// Remove a label from an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn remove_label(&mut self, issue_id: &str, label: &str, actor: &str) -> Result<bool> {
validate_storage_label(label)?;
self.mutate("remove_label", actor, |conn, ctx| {
match Self::issue_status_in_tx(conn, issue_id)? {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!("cannot remove label from tombstone issue: {issue_id}"),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: issue_id.to_string(),
});
}
}
let rows = conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ? AND label = ?",
&[SqliteValue::from(issue_id), SqliteValue::from(label)],
)?;
if rows > 0 {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(
EventType::LabelRemoved,
issue_id,
Some(format!("Removed label {label}")),
);
ctx.mark_dirty(issue_id);
}
Ok(rows > 0)
})
}
/// Remove one label from many issues in a single storage mutation.
///
/// Returns the set of issue IDs that actually lost the label. IDs that did
/// not have the label remain idempotent no-ops, matching [`Self::remove_label`].
///
/// # Errors
///
/// Returns an error if any target issue is missing or tombstoned.
#[allow(clippy::too_many_lines)]
pub fn remove_label_from_issues_bulk(
&mut self,
issue_ids: &[String],
label: &str,
actor: &str,
) -> Result<HashSet<String>> {
validate_storage_label(label)?;
if issue_ids.is_empty() {
return Ok(HashSet::new());
}
let unique_issue_ids = dedupe_preserving_order(issue_ids);
self.mutate("remove_label_from_issues_bulk", actor, |conn, ctx| {
let mut changed_ids = HashSet::new();
let now_str = Utc::now().to_rfc3339();
// Label lookups/deletes bind one label plus every issue id, so
// reserve one parameter slot for the label value.
for chunk in unique_issue_ids.chunks(SQLITE_VAR_LIMIT - 1) {
let placeholders = vec!["?"; chunk.len()];
let params = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect::<Vec<_>>();
let rows = conn.query_with_params(
&format!(
"SELECT id, status FROM issues WHERE id IN ({})",
placeholders.join(",")
),
¶ms,
)?;
let mut statuses = HashMap::with_capacity(rows.len());
for row in &rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.ok_or_else(|| {
BeadsError::Config(
"issues row missing id during bulk label remove".to_string(),
)
})?
.to_string();
let status = row.get(1).and_then(SqliteValue::as_text).ok_or_else(|| {
BeadsError::Config(format!(
"issues row missing status during bulk label remove for {id}"
))
})?;
statuses.insert(id, Status::from_str(status)?);
}
for issue_id in chunk {
match statuses.get(issue_id) {
Some(Status::Tombstone) => {
return Err(BeadsError::Validation {
field: "issue_id".to_string(),
reason: format!(
"cannot remove label from tombstone issue: {issue_id}"
),
});
}
Some(_) => {}
None => {
return Err(BeadsError::IssueNotFound {
id: issue_id.clone(),
});
}
}
}
let mut label_params = Vec::with_capacity(chunk.len() + 1);
label_params.push(SqliteValue::from(label));
label_params.extend(chunk.iter().map(|id| SqliteValue::from(id.as_str())));
let existing_rows = conn.query_with_params(
&format!(
"SELECT issue_id FROM labels WHERE label = ? AND issue_id IN ({})",
placeholders.join(",")
),
&label_params,
)?;
let removable_ids = existing_rows
.iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(ToString::to_string)
})
.collect::<HashSet<_>>();
if removable_ids.is_empty() {
continue;
}
let removable_ids = chunk
.iter()
.filter(|issue_id| removable_ids.contains(issue_id.as_str()))
.collect::<Vec<_>>();
let remove_placeholders = vec!["?"; removable_ids.len()];
let mut remove_params = Vec::with_capacity(removable_ids.len() + 1);
remove_params.push(SqliteValue::from(label));
remove_params.extend(
removable_ids
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str())),
);
conn.execute_with_params(
&format!(
"DELETE FROM labels WHERE label = ? AND issue_id IN ({})",
remove_placeholders.join(",")
),
&remove_params,
)?;
for issue_id in &removable_ids {
ctx.record_event(
EventType::LabelRemoved,
issue_id,
Some(format!("Removed label {label}")),
);
ctx.mark_dirty(issue_id);
changed_ids.insert((*issue_id).clone());
}
for update_chunk in removable_ids.chunks(SQLITE_VAR_LIMIT - 1) {
let update_placeholders = vec!["?"; update_chunk.len()];
let mut update_params = Vec::with_capacity(update_chunk.len() + 1);
update_params.push(SqliteValue::from(now_str.as_str()));
update_params.extend(
update_chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str())),
);
conn.execute_with_params(
&format!(
"UPDATE issues SET updated_at = ? WHERE id IN ({})",
update_placeholders.join(",")
),
&update_params,
)?;
}
}
Ok(changed_ids)
})
}
/// Remove all labels from an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn remove_all_labels(&mut self, issue_id: &str, actor: &str) -> Result<usize> {
self.mutate("remove_all_labels", actor, |conn, ctx| {
Self::ensure_issue_mutable_in_tx(conn, issue_id, "remove labels from")?;
let rows = conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
if rows > 0 {
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(
EventType::LabelRemoved,
issue_id,
Some(format!("Removed {rows} labels")),
);
ctx.mark_dirty(issue_id);
}
Ok(rows)
})
}
/// Set all labels for an issue (replace existing).
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn set_labels(&mut self, issue_id: &str, labels: &[String], actor: &str) -> Result<()> {
self.mutate("set_labels", actor, |conn, ctx| {
Self::ensure_issue_mutable_in_tx(conn, issue_id, "set labels on")?;
let old_rows = conn.query_with_params(
"SELECT label FROM labels WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let old_labels_raw: Vec<String> = old_rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
let old_labels = dedupe_preserving_order(&old_labels_raw);
let desired_labels = dedupe_preserving_order(labels);
validate_storage_labels(&desired_labels)?;
let old_matches_desired = old_labels.len() == desired_labels.len()
&& old_labels
.iter()
.all(|label| desired_labels.contains(label));
let db_has_duplicate_labels = old_labels_raw.len() != old_labels.len();
if old_matches_desired && !db_has_duplicate_labels {
return Ok(());
}
conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let mut seen_labels = HashSet::new();
for label in &desired_labels {
if !seen_labels.insert(label.as_str()) {
continue;
}
conn.execute_with_params(
"INSERT INTO labels (issue_id, label) VALUES (?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(label.as_str()),
],
)?;
}
// Record changes
let removed: Vec<_> = old_labels
.iter()
.filter(|label| !desired_labels.contains(label))
.collect();
let added: Vec<_> = desired_labels
.iter()
.filter(|label| !old_labels.contains(label))
.collect();
if !removed.is_empty() || !added.is_empty() || db_has_duplicate_labels {
let mut details = Vec::new();
if !removed.is_empty() {
details.push(format!(
"removed: {}",
removed
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
if !added.is_empty() {
details.push(format!(
"added: {}",
added
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
if db_has_duplicate_labels && removed.is_empty() && added.is_empty() {
details.push("normalized duplicate labels".to_string());
}
ctx.record_event(
EventType::Updated,
issue_id,
Some(format!("Labels {}", details.join("; "))),
);
ctx.mark_dirty(issue_id);
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
}
Ok(())
})
}
/// Get labels for an issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_labels(&self, issue_id: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
"SELECT label FROM labels WHERE issue_id = ? ORDER BY label",
&[SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Get labels for multiple issues efficiently.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_labels_for_issues(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, Vec<String>>> {
// Stay below SQLite's common 999-variable ceiling while keeping the
// default scheduler candidate window to one evidence-loading round trip.
const SQLITE_VAR_LIMIT: usize = 900;
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, Vec<String>> = HashMap::new();
// SQLite has a finite variable limit (default 999). Chunk to avoid query failures.
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT issue_id, label FROM labels WHERE issue_id IN ({}) ORDER BY issue_id, label",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|s| SqliteValue::from(s.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.entry(issue_id).or_default().push(label);
}
}
Ok(map)
}
/// Get all labels for all issues as a map of issue_id -> labels.
///
/// Used for export and sync operations that need complete label state.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_labels(&self) -> Result<HashMap<String, Vec<String>>> {
let rows = self
.conn
.query("SELECT issue_id, label FROM labels ORDER BY issue_id, label")?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.entry(issue_id).or_default().push(label);
}
Ok(map)
}
/// Get all raw label rows without ordering or grouping.
///
/// Use this for aggregate callers that do their own counting and do not
/// need export-stable ordering.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub(crate) fn list_label_pairs_unordered(&self) -> Result<Vec<(String, String)>> {
let rows = self.conn.query("SELECT issue_id, label FROM labels")?;
let mut pairs = Vec::with_capacity(rows.len());
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
pairs.push((issue_id, label));
}
Ok(pairs)
}
/// Get labels plus dependency/dependent counts for every issue that has any
/// list relation metadata.
///
/// This is tuned for full structured list output: it keeps the same SQL
/// scans as the separate helpers but stores everything in a single map so
/// callers do one lookup per issue instead of three.
///
/// # Errors
///
/// Returns an error if any database query fails.
pub(crate) fn get_all_list_relation_metadata(
&self,
) -> Result<HashMap<String, ListRelationMetadata>> {
let label_rows = self
.conn
.query("SELECT issue_id, label FROM labels ORDER BY issue_id, label")?;
let dependency_rows = self
.conn
.query("SELECT issue_id, depends_on_id FROM dependencies")?;
let capacity = label_rows
.len()
.saturating_add(dependency_rows.len().saturating_mul(2));
let mut map: HashMap<String, ListRelationMetadata> = HashMap::with_capacity(capacity);
for row in &label_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.entry(issue_id).or_default().labels.push(label);
}
for row in &dependency_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let depends_on_id = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
if !issue_id.is_empty() {
map.entry(issue_id).or_default().dependency_count += 1;
}
if !depends_on_id.is_empty() {
map.entry(depends_on_id).or_default().dependent_count += 1;
}
}
Ok(map)
}
/// Get all labels attached to exportable issues.
///
/// This mirrors the JSONL export issue filter so relation hydration does
/// not observe rows for excluded ephemerals or wisps.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_labels_for_export(&self) -> Result<HashMap<String, Vec<String>>> {
let rows = self.conn.query(
"SELECT labels.issue_id, labels.label
FROM labels
INNER JOIN issues ON issues.id = labels.issue_id
WHERE (issues.ephemeral = 0 OR issues.ephemeral IS NULL)
AND issues.id NOT LIKE '%-wisp-%'
ORDER BY labels.issue_id, labels.label",
)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let label = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.entry(issue_id).or_default().push(label);
}
Ok(map)
}
/// Get all unique labels with their issue counts.
///
/// Returns a vector of (label, count) pairs sorted alphabetically by label.
/// Excludes labels on tombstoned (deleted) issues.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_unique_labels_with_counts(&self) -> Result<Vec<(String, i64)>> {
let tombstone_rows = self
.conn
.query("SELECT id FROM issues WHERE status = 'tombstone'")?;
let tombstone_ids: HashSet<String> = tombstone_rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(str::to_owned))
.collect();
let rows = self.conn.query("SELECT label, issue_id FROM labels")?;
let mut counts = BTreeMap::new();
for row in &rows {
let Some(label) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(issue_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if tombstone_ids.contains(issue_id) {
continue;
}
*counts.entry(label.to_string()).or_insert(0) += 1;
}
Ok(counts.into_iter().collect())
}
/// Rename a label across all issues.
///
/// Returns the number of issues affected.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn rename_label(&mut self, old_name: &str, new_name: &str, actor: &str) -> Result<usize> {
validate_storage_label(old_name)?;
validate_storage_label(new_name)?;
if old_name == new_name {
return Ok(0);
}
self.mutate("rename_label", actor, |conn, ctx| {
let id_rows = conn.query_with_params(
"SELECT l.issue_id
FROM labels l
JOIN issues i ON l.issue_id = i.id
WHERE l.label = ? AND i.status != 'tombstone'",
&[SqliteValue::from(old_name)],
)?;
let issue_ids: Vec<String> = id_rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
let conflict_rows = conn.query_with_params(
"SELECT l.issue_id
FROM labels l
JOIN issues i ON l.issue_id = i.id
WHERE l.label = ?
AND i.status != 'tombstone'
AND l.issue_id IN (SELECT issue_id FROM labels WHERE label = ?)",
&[SqliteValue::from(new_name), SqliteValue::from(old_name)],
)?;
let conflicts: Vec<String> = conflict_rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
for conflict_id in &conflicts {
conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ? AND label = ?",
&[
SqliteValue::from(conflict_id.as_str()),
SqliteValue::from(old_name),
],
)?;
ctx.mark_dirty(conflict_id);
}
let renamed = conn.execute_with_params(
"UPDATE labels
SET label = ?
WHERE label = ?
AND issue_id IN (SELECT id FROM issues WHERE status != 'tombstone')",
&[SqliteValue::from(new_name), SqliteValue::from(old_name)],
)?;
let now = Utc::now().to_rfc3339();
for issue_id in &issue_ids {
ctx.record_event(
EventType::LabelRemoved,
issue_id,
Some(format!("Renamed label {old_name} to {new_name}")),
);
ctx.mark_dirty(issue_id);
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(now.as_str()),
SqliteValue::from(issue_id.as_str()),
],
)?;
}
Ok(renamed + conflicts.len())
})
}
/// Get comments for an issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
let rows = self.conn.query_with_params(
"SELECT id, issue_id, author, text, created_at
FROM comments
WHERE issue_id = ?
ORDER BY created_at ASC, id ASC",
&[SqliteValue::from(issue_id)],
)?;
rows.iter().map(comment_from_row).collect()
}
/// Get comments for multiple issues in batch.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_comments_for_issues(
&self,
issue_ids: &[String],
) -> Result<std::collections::HashMap<String, Vec<Comment>>> {
const SQLITE_VAR_LIMIT: usize = 900;
let mut map: std::collections::HashMap<String, Vec<Comment>> =
std::collections::HashMap::new();
if issue_ids.is_empty() {
return Ok(map);
}
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT id, issue_id, author, text, created_at
FROM comments
WHERE issue_id IN ({})
ORDER BY issue_id ASC, created_at ASC, id ASC",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let comment = comment_from_row(row)?;
map.entry(comment.issue_id.clone())
.or_default()
.push(comment);
}
}
Ok(map)
}
/// Get the latest comments for multiple issues in batch.
///
/// Rows are returned in ascending timestamp order within each issue so
/// callers can reuse the same presentation logic as [`Self::get_comments`]
/// without materializing older comments they will discard.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_latest_comments_for_issues(
&self,
issue_ids: &[String],
limit: usize,
) -> Result<std::collections::HashMap<String, Vec<Comment>>> {
const SQLITE_VAR_LIMIT: usize = 899;
let mut map: std::collections::HashMap<String, Vec<Comment>> =
std::collections::HashMap::new();
if issue_ids.is_empty() || limit == 0 {
return Ok(map);
}
let row_limit = i64::try_from(limit).unwrap_or(i64::MAX);
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT id, issue_id, author, text, created_at
FROM (
SELECT id, issue_id, author, text, created_at,
ROW_NUMBER() OVER (
PARTITION BY issue_id
ORDER BY created_at DESC, id DESC
) AS row_number
FROM comments
WHERE issue_id IN ({})
)
WHERE row_number <= ?
ORDER BY issue_id ASC, created_at ASC, id ASC",
placeholders.join(",")
);
let mut params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
params.push(SqliteValue::from(row_limit));
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let comment = comment_from_row(row)?;
map.entry(comment.issue_id.clone())
.or_default()
.push(comment);
}
}
Ok(map)
}
/// Count how many audit events belong to an issue.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_issue_events(&self, issue_id: &str) -> Result<usize> {
let count = self
.conn
.query_row_with_params(
"SELECT count(*) FROM events WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
/// Add a comment to an issue.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn add_comment(&mut self, issue_id: &str, author: &str, text: &str) -> Result<Comment> {
validate_new_comment(issue_id, author, text)?;
self.mutate("add_comment", author, |conn, ctx| {
Self::ensure_issue_mutable_in_tx(conn, issue_id, "add comment to")?;
let comment_id = insert_comment_row(conn, issue_id, author, text)?;
conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from(issue_id),
],
)?;
ctx.record_event(EventType::Commented, issue_id, Some(text.to_string()));
ctx.mark_dirty(issue_id);
fetch_comment(conn, comment_id)
})
}
/// Get dependencies with metadata.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependencies_with_metadata(
&self,
issue_id: &str,
) -> Result<Vec<IssueWithDependencyMetadata>> {
let rows = self.conn.query_with_params(
"SELECT d.depends_on_id, i.title, i.status, i.priority, d.type, i.created_at
FROM dependencies d
LEFT JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id = ?
ORDER BY COALESCE(i.priority, 2) ASC, i.created_at DESC, d.depends_on_id ASC",
&[SqliteValue::from(issue_id)],
)?;
rows.iter()
.map(|row| dependency_metadata_from_row(row, "dependency target", true))
.collect()
}
/// Get dependents with metadata.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependents_with_metadata(
&self,
issue_id: &str,
) -> Result<Vec<IssueWithDependencyMetadata>> {
let rows = self.conn.query_with_params(
"SELECT d.issue_id, i.title, i.status, i.priority, d.type, i.created_at
FROM dependencies d
LEFT JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id = ?
ORDER BY COALESCE(i.priority, 2) ASC, i.created_at DESC, d.issue_id ASC",
&[SqliteValue::from(issue_id)],
)?;
rows.iter()
.map(|row| dependency_metadata_from_row(row, "dependent issue", false))
.collect()
}
/// Prefetch all reverse-dependency edges for blocking relationship types
/// (`blocks`, `conditional-blocks`, `waits-for`, `parent-child`).
///
/// Returns a map from `depends_on_id` → `Vec<IssueWithDependencyMetadata>`,
/// enabling in-memory graph traversal without per-node queries.
pub fn prefetch_blocking_dependents(
&self,
) -> Result<HashMap<String, Vec<IssueWithDependencyMetadata>>> {
let rows = self.conn.query(
"SELECT d.depends_on_id, d.issue_id, i.title, i.status, i.priority, d.type
FROM dependencies d
LEFT JOIN issues i ON d.issue_id = i.id
WHERE d.type IN ('blocks', 'conditional-blocks', 'waits-for', 'parent-child')
ORDER BY COALESCE(i.priority, 2) ASC, i.created_at DESC, d.issue_id ASC",
)?;
let mut map: HashMap<String, Vec<IssueWithDependencyMetadata>> = HashMap::new();
for row in &rows {
let Some(depends_on_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(issue_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let dep_type = row
.get(5)
.and_then(SqliteValue::as_text)
.unwrap_or("blocks")
.to_string();
let title = row.get(2).and_then(SqliteValue::as_text);
let status = row.get(3).and_then(SqliteValue::as_text);
let priority = row.get(4).and_then(SqliteValue::as_integer);
let meta = match (title, status, priority) {
(Some(title), Some(status), Some(priority)) => IssueWithDependencyMetadata {
id: issue_id.to_string(),
title: title.to_string(),
status: parse_status(Some(status)),
priority: Priority(i32::try_from(priority).unwrap_or(2)),
dep_type,
},
_ => IssueWithDependencyMetadata {
id: issue_id.to_string(),
title: format!("[missing issue: {issue_id}]"),
status: Status::Tombstone,
priority: Priority::MEDIUM,
dep_type,
},
};
map.entry(depends_on_id.to_string()).or_default().push(meta);
}
Ok(map)
}
/// Get parent issue ID.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_parent_id(&self, issue_id: &str) -> Result<Option<String>> {
match self.conn.query_row_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type = 'parent-child' ORDER BY rowid DESC LIMIT 1",
&[SqliteValue::from(issue_id)],
) {
Ok(row) => Ok(row.get(0).and_then(SqliteValue::as_text).map(String::from)),
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Get IDs of issues that depend on this one.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependents(&self, issue_id: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
"SELECT issue_id FROM dependencies WHERE depends_on_id = ?",
&[SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Get IDs of issues that block this one (respects parent-child direction).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocker_ids(&self, issue_id: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
r"
SELECT depends_on_id
FROM dependencies
WHERE issue_id = ?
AND type IN ('blocks', 'conditional-blocks', 'waits-for')
UNION
SELECT issue_id FROM dependencies WHERE depends_on_id = ? AND type = 'parent-child'
",
&[SqliteValue::from(issue_id), SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Get IDs of issues that are blocked by this one (respects parent-child direction).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocked_issue_ids(&self, issue_id: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
r"
SELECT issue_id
FROM dependencies
WHERE depends_on_id = ?
AND type IN ('blocks', 'conditional-blocks', 'waits-for')
UNION
SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type = 'parent-child'
",
&[SqliteValue::from(issue_id), SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Get IDs of issues that this one depends on.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependencies(&self, issue_id: &str) -> Result<Vec<String>> {
let rows = self.conn.query_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Count how many dependencies an issue has.
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
pub fn count_dependencies(&self, issue_id: &str) -> Result<usize> {
let row = self.conn.query_row_with_params(
"SELECT count(*) FROM dependencies WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(count as usize)
}
/// Count how many issues depend on this one.
///
/// # Errors
///
/// Returns an error if the database query fails.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
pub fn count_dependents(&self, issue_id: &str) -> Result<usize> {
let row = self.conn.query_row_with_params(
"SELECT count(*) FROM dependencies WHERE depends_on_id = ?",
&[SqliteValue::from(issue_id)],
)?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(count as usize)
}
/// Find the next available child number for a parent issue.
///
/// Looks for existing issues with IDs like `{parent_id}.N` and returns the next
/// available number. For example, if `bd-abc.1` and `bd-abc.2` exist, returns 3.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn next_child_number(&self, parent_id: &str) -> Result<u32> {
// First, check the child_counters table (source of truth)
match self.conn.query_row_with_params(
"SELECT last_child FROM child_counters WHERE parent_id = ?",
&[SqliteValue::from(parent_id)],
) {
Ok(row) => {
if let Some(last_child) = row.get(0).and_then(SqliteValue::as_integer) {
return Ok(u32::try_from(last_child).unwrap_or(0).saturating_add(1));
}
}
Err(fsqlite_error::FrankenError::QueryReturnedNoRows) => {}
Err(e) => return Err(e.into()),
}
// Fallback: Scan issues table for legacy data or missing counter
// Find all existing child IDs matching the pattern {parent_id}.N
// Escape LIKE wildcards in parent_id to prevent injection
let escaped_parent = escape_like_pattern(parent_id);
let pattern = format!("{escaped_parent}.%");
let ids_rows = self.conn.query_with_params(
"SELECT id FROM issues WHERE id LIKE ? ESCAPE '\\'",
&[SqliteValue::from(pattern.as_str())],
)?;
let ids: Vec<String> = ids_rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
// Extract child numbers and find the maximum
let prefix_with_dot = format!("{parent_id}.");
let max_child = ids
.iter()
.filter_map(|id| {
id.strip_prefix(&prefix_with_dot)
.and_then(|suffix| {
// Handle both simple children (parent.1) and nested (parent.1.2)
// We only care about direct children, so take the first segment
suffix.split('.').next()
})
.and_then(|num_str| num_str.parse::<u32>().ok())
})
.max()
.unwrap_or(0);
// Use saturating_add to prevent overflow (extremely unlikely but safe)
Ok(max_child.saturating_add(1))
}
/// Internal helper to update a child counter within a transaction.
fn update_child_counter_in_tx(
conn: &Connection,
parent_id: &str,
child_number: u32,
) -> Result<()> {
// Check current value
let current_max = match conn.query_row_with_params(
"SELECT last_child FROM child_counters WHERE parent_id = ?",
&[SqliteValue::from(parent_id)],
) {
Ok(row) => row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0),
Err(fsqlite_error::FrankenError::QueryReturnedNoRows) => 0,
Err(e) => return Err(e.into()),
};
if i64::from(child_number) > current_max {
// DELETE + INSERT to simulate UPSERT (fsqlite limitation).
// FK enforcement is disabled by the caller's transaction wrapper
// to avoid false FK violations from fsqlite (#215).
conn.execute_with_params(
"DELETE FROM child_counters WHERE parent_id = ?",
&[SqliteValue::from(parent_id)],
)?;
conn.execute_with_params(
"INSERT INTO child_counters (parent_id, last_child) VALUES (?, ?)",
&[
SqliteValue::from(parent_id),
SqliteValue::from(i64::from(child_number)),
],
)?;
}
Ok(())
}
/// Count dependencies for multiple issues efficiently.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_dependencies_for_issues(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, usize>> {
const SQLITE_VAR_LIMIT: usize = 900;
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, usize> = HashMap::new();
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT issue_id, COUNT(*) FROM dependencies WHERE issue_id IN ({}) GROUP BY issue_id",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|s| SqliteValue::from(s.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
map.insert(issue_id, usize::try_from(count).unwrap_or(0));
}
}
Ok(map)
}
/// Fetch reverse-dependency edges for a bounded set of blocking roots.
///
/// Returns a map from blocker-graph root ID to dependents. Standard
/// dependency rows use `depends_on_id` as the root; `parent-child` rows are
/// reversed because parents are blocked by children.
/// Unlike [`Self::prefetch_blocking_dependents`], this keeps focused graph
/// traversals from hydrating the entire workspace dependency graph.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocking_dependents_for_issue_ids(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, Vec<IssueWithDependencyMetadata>>> {
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, Vec<IssueWithDependencyMetadata>> = HashMap::new();
let chunk_size = SQLITE_VAR_LIMIT.saturating_div(2).max(1);
for chunk in issue_ids.chunks(chunk_size) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT root_id, dependent_id, title, status, priority, type
FROM (
SELECT d.depends_on_id AS root_id,
d.issue_id AS dependent_id,
i.title AS title,
i.status AS status,
i.priority AS priority,
i.created_at AS created_at,
d.type AS type
FROM dependencies d
LEFT JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id IN ({placeholders})
AND d.type IN ('blocks', 'conditional-blocks', 'waits-for')
UNION ALL
SELECT d.issue_id AS root_id,
d.depends_on_id AS dependent_id,
i.title AS title,
i.status AS status,
i.priority AS priority,
i.created_at AS created_at,
d.type AS type
FROM dependencies d
LEFT JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id IN ({placeholders})
AND d.type = 'parent-child'
)
ORDER BY COALESCE(priority, 2) ASC, created_at DESC, dependent_id ASC",
placeholders = placeholders.join(",")
);
let mut params: Vec<SqliteValue> = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect();
params.extend(
chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str())),
);
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(root_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(dependent_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let dep_type = row
.get(5)
.and_then(SqliteValue::as_text)
.unwrap_or("blocks")
.to_string();
let title = row.get(2).and_then(SqliteValue::as_text);
let status = row.get(3).and_then(SqliteValue::as_text);
let priority = row.get(4).and_then(SqliteValue::as_integer);
let meta = match (title, status, priority) {
(Some(title), Some(status), Some(priority)) => IssueWithDependencyMetadata {
id: dependent_id.to_string(),
title: title.to_string(),
status: parse_status(Some(status)),
priority: Priority(i32::try_from(priority).unwrap_or(2)),
dep_type,
},
_ => IssueWithDependencyMetadata {
id: dependent_id.to_string(),
title: format!("[missing issue: {dependent_id}]"),
status: Status::Tombstone,
priority: Priority::MEDIUM,
dep_type,
},
};
map.entry(root_id.to_string()).or_default().push(meta);
}
}
Ok(map)
}
/// Mirror of [`Self::get_blocking_dependents_for_issue_ids`]: for each id,
/// the issues it *depends on* rather than the issues that depend on it.
///
/// Backs `br graph --dependencies` (`beads_rust-mf72`), which answers "what
/// is blocking this?" where the default walk answers "what does closing
/// this unblock?". The two queries are exact inverses, including the
/// `parent-child` special case, which the dependents query deliberately
/// walks the other way round.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_blocking_dependencies_for_issue_ids(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, Vec<IssueWithDependencyMetadata>>> {
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, Vec<IssueWithDependencyMetadata>> = HashMap::new();
let chunk_size = SQLITE_VAR_LIMIT.saturating_div(2).max(1);
for chunk in issue_ids.chunks(chunk_size) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT root_id, dependency_id, title, status, priority, type
FROM (
SELECT d.issue_id AS root_id,
d.depends_on_id AS dependency_id,
i.title AS title,
i.status AS status,
i.priority AS priority,
i.created_at AS created_at,
d.type AS type
FROM dependencies d
LEFT JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id IN ({placeholders})
AND d.type IN ('blocks', 'conditional-blocks', 'waits-for')
UNION ALL
SELECT d.depends_on_id AS root_id,
d.issue_id AS dependency_id,
i.title AS title,
i.status AS status,
i.priority AS priority,
i.created_at AS created_at,
d.type AS type
FROM dependencies d
LEFT JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id IN ({placeholders})
AND d.type = 'parent-child'
)
ORDER BY COALESCE(priority, 2) ASC, created_at DESC, dependency_id ASC",
placeholders = placeholders.join(",")
);
let mut params: Vec<SqliteValue> = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect();
params.extend(
chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str())),
);
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(root_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(dependency_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let dep_type = row
.get(5)
.and_then(SqliteValue::as_text)
.unwrap_or("blocks")
.to_string();
let title = row.get(2).and_then(SqliteValue::as_text);
let status = row.get(3).and_then(SqliteValue::as_text);
let priority = row.get(4).and_then(SqliteValue::as_integer);
let meta = match (title, status, priority) {
(Some(title), Some(status), Some(priority)) => IssueWithDependencyMetadata {
id: dependency_id.to_string(),
title: title.to_string(),
status: parse_status(Some(status)),
priority: Priority(i32::try_from(priority).unwrap_or(2)),
dep_type,
},
_ => IssueWithDependencyMetadata {
id: dependency_id.to_string(),
title: format!("[missing issue: {dependency_id}]"),
status: Status::Tombstone,
priority: Priority::MEDIUM,
dep_type,
},
};
map.entry(root_id.to_string()).or_default().push(meta);
}
}
Ok(map)
}
/// Count dependencies and dependents for multiple issues with one round-trip per chunk.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_relation_counts_for_issues(
&self,
issue_ids: &[String],
) -> Result<(HashMap<String, usize>, HashMap<String, usize>)> {
// Stay below SQLite's common 999-variable ceiling while keeping the
// default scheduler candidate window to one evidence-loading round trip.
// Avoid CTE VALUES materialization, which is the primary root-page
// collision trigger for fsqlite's MemDatabase.
const SQLITE_VAR_LIMIT: usize = 900;
if issue_ids.is_empty() {
return Ok((HashMap::new(), HashMap::new()));
}
let mut dependency_counts: HashMap<String, usize> = HashMap::new();
let mut dependent_counts: HashMap<String, usize> = HashMap::new();
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let joined = placeholders.join(",");
let params: Vec<SqliteValue> = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect();
// Query dependency counts (issue_id = the issue that depends on something)
let dep_sql = format!(
"SELECT issue_id, COUNT(*) FROM dependencies WHERE issue_id IN ({joined}) GROUP BY issue_id"
);
let rows = self.conn.query_with_params(&dep_sql, ¶ms)?;
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
if count > 0 {
*dependency_counts.entry(issue_id).or_insert(0) +=
usize::try_from(count).unwrap_or(0);
}
}
// Query dependent counts (depends_on_id = the issue that others depend on)
let dpt_sql = format!(
"SELECT depends_on_id, COUNT(*) FROM dependencies WHERE depends_on_id IN ({joined}) GROUP BY depends_on_id"
);
let rows = self.conn.query_with_params(&dpt_sql, ¶ms)?;
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
if count > 0 {
*dependent_counts.entry(issue_id).or_insert(0) +=
usize::try_from(count).unwrap_or(0);
}
}
}
Ok((dependency_counts, dependent_counts))
}
/// Count dependencies and dependents for every issue in the dependency table.
///
/// This is faster than chunked `IN (...)` probes when the caller has already
/// selected a large result set and only needs to project counts onto those
/// issue IDs.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_all_relation_counts(
&self,
) -> Result<(HashMap<String, usize>, HashMap<String, usize>)> {
let dependency_rows = self
.conn
.query("SELECT issue_id, COUNT(*) FROM dependencies GROUP BY issue_id")?;
let mut dependency_counts: HashMap<String, usize> = HashMap::new();
for row in &dependency_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
if count > 0 {
dependency_counts.insert(issue_id, usize::try_from(count).unwrap_or(0));
}
}
let dependent_rows = self
.conn
.query("SELECT depends_on_id, COUNT(*) FROM dependencies GROUP BY depends_on_id")?;
let mut dependent_counts: HashMap<String, usize> = HashMap::new();
for row in &dependent_rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
if count > 0 {
dependent_counts.insert(issue_id, usize::try_from(count).unwrap_or(0));
}
}
Ok((dependency_counts, dependent_counts))
}
/// Count dependents for multiple issues efficiently.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_dependents_for_issues(
&self,
issue_ids: &[String],
) -> Result<HashMap<String, usize>> {
const SQLITE_VAR_LIMIT: usize = 900;
if issue_ids.is_empty() {
return Ok(HashMap::new());
}
let mut map: HashMap<String, usize> = HashMap::new();
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT depends_on_id, COUNT(*) FROM dependencies WHERE depends_on_id IN ({}) GROUP BY depends_on_id",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|s| SqliteValue::from(s.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let count = row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0);
map.insert(issue_id, usize::try_from(count).unwrap_or(0));
}
}
Ok(map)
}
/// Fetch a config value.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_config(&self, key: &str) -> Result<Option<String>> {
match self.conn.query_row_with_params(
"SELECT value FROM config WHERE key = ?",
&[SqliteValue::from(key)],
) {
Ok(row) => Ok(row.get(0).and_then(SqliteValue::as_text).map(String::from)),
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Fetch all config values from the config table.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_config(&self) -> Result<HashMap<String, String>> {
let rows = self.conn.query("SELECT key, value FROM config")?;
let mut map = HashMap::new();
for row in &rows {
let key = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let value = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.insert(key, value);
}
Ok(map)
}
/// Set a config value.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn set_config(&mut self, key: &str, value: &str) -> Result<()> {
let stored_value = if matches!(key, "issue_prefix" | "issue-prefix" | "prefix") {
normalize_prefix(value)
} else {
value.to_string()
};
self.with_write_transaction(|storage| {
storage.conn.execute_with_params(
"DELETE FROM config WHERE key = ?",
&[SqliteValue::from(key)],
)?;
storage.conn.execute_with_params(
"INSERT INTO config (key, value) VALUES (?, ?)",
&[
SqliteValue::from(key),
SqliteValue::from(stored_value.as_str()),
],
)?;
Ok(())
})
}
/// Delete a config value.
///
/// Returns `true` if a value was deleted, `false` if the key didn't exist.
///
/// # Errors
///
/// Returns an error if the database delete fails.
pub fn delete_config(&mut self, key: &str) -> Result<bool> {
self.with_write_transaction(|storage| {
let deleted = storage.conn.execute_with_params(
"DELETE FROM config WHERE key = ?",
&[SqliteValue::from(key)],
)?;
Ok(deleted > 0)
})
}
// ========================================================================
// Export-related methods
// ========================================================================
/// Get all issues for JSONL export.
///
/// Includes tombstones (for sync propagation), excludes ephemerals and wisps.
/// Returns issues sorted by ID for deterministic output.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_issues_for_export(&self) -> Result<Vec<Issue>> {
let sql = r"SELECT 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
FROM issues
WHERE (ephemeral = 0 OR ephemeral IS NULL)
AND id NOT LIKE '%-wisp-%'
ORDER BY id ASC";
let rows = self.conn.query(sql)?;
let mut issues = Vec::with_capacity(rows.len());
for row in &rows {
issues.push(Self::issue_from_row(row)?);
}
self.attach_close_bypass_audit_for_export(&mut issues)?;
Ok(issues)
}
/// Project the close-policy bypass audit trail from `close_metadata`
/// onto issues being exported (GitHub #474). Without this a
/// `--bypass-policy` close is recorded only in the gitignored local
/// database and is invisible in the shared JSONL record.
///
/// # Errors
///
/// Returns an error if the audit query fails.
pub(crate) fn attach_close_bypass_audit_for_export(&self, issues: &mut [Issue]) -> Result<()> {
if issues.is_empty() || !crate::storage::schema::table_exists(&self.conn, "close_metadata")
{
return Ok(());
}
let rows = self.conn.query(
"SELECT issue_id, bypass_reason, policy_gates_fired
FROM close_metadata WHERE bypassed_policy = 1",
)?;
if rows.is_empty() {
return Ok(());
}
let mut audit: HashMap<String, (Option<String>, Option<Vec<String>>)> = HashMap::new();
for row in &rows {
let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let reason = row.get(1).and_then(SqliteValue::as_text).map(String::from);
let gates = row
.get(2)
.and_then(SqliteValue::as_text)
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok());
audit.insert(issue_id.to_string(), (reason, gates));
}
for issue in issues.iter_mut() {
if let Some((reason, gates)) = audit.get(&issue.id) {
issue.bypassed_policy = Some(true);
issue.bypass_reason.clone_from(reason);
issue.policy_gates_fired.clone_from(gates);
}
}
Ok(())
}
/// Get all dependency records for all issues.
///
/// Returns a map from `issue_id` to its list of Dependency records.
/// This avoids N+1 queries when populating issues for export.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_dependency_records(
&self,
) -> Result<HashMap<String, Vec<crate::model::Dependency>>> {
use crate::model::{Dependency, DependencyType};
let rows = self.conn.query(
"SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
FROM dependencies
ORDER BY issue_id, depends_on_id",
)?;
let mut map: HashMap<String, Vec<Dependency>> = HashMap::new();
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let dep = Dependency {
issue_id: issue_id.clone(),
depends_on_id: row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
dep_type: row
.get(2)
.and_then(SqliteValue::as_text)
.and_then(|s| s.parse().ok())
.unwrap_or(DependencyType::Blocks),
created_at: parse_datetime_value(row.get(3))?,
created_by: row.get(4).and_then(SqliteValue::as_text).map(String::from),
metadata: row.get(5).and_then(SqliteValue::as_text).map(String::from),
thread_id: row.get(6).and_then(SqliteValue::as_text).map(String::from),
};
map.entry(issue_id).or_default().push(dep);
}
Ok(map)
}
/// Get all dependency records whose source issue is exportable.
///
/// This keeps full-scan export hydration semantically equivalent to the
/// previous ID-filtered batch queries.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependency_records_for_export(
&self,
) -> Result<HashMap<String, Vec<crate::model::Dependency>>> {
use crate::model::{Dependency, DependencyType};
let rows = self.conn.query(
"SELECT dependencies.issue_id, dependencies.depends_on_id, dependencies.type,
dependencies.created_at, dependencies.created_by, dependencies.metadata,
dependencies.thread_id
FROM dependencies
INNER JOIN issues ON issues.id = dependencies.issue_id
WHERE (issues.ephemeral = 0 OR issues.ephemeral IS NULL)
AND issues.id NOT LIKE '%-wisp-%'
ORDER BY dependencies.issue_id, dependencies.depends_on_id",
)?;
let mut map: HashMap<String, Vec<Dependency>> = HashMap::new();
for row in &rows {
let issue_id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let dep = Dependency {
issue_id: issue_id.clone(),
depends_on_id: row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
dep_type: row
.get(2)
.and_then(SqliteValue::as_text)
.and_then(|s| s.parse().ok())
.unwrap_or(DependencyType::Blocks),
created_at: parse_datetime_value(row.get(3))?,
created_by: row.get(4).and_then(SqliteValue::as_text).map(String::from),
metadata: row.get(5).and_then(SqliteValue::as_text).map(String::from),
thread_id: row.get(6).and_then(SqliteValue::as_text).map(String::from),
};
map.entry(issue_id).or_default().push(dep);
}
Ok(map)
}
/// Get all comments for all issues.
///
/// Returns a map from `issue_id` to its list of comments.
/// This avoids N+1 queries when populating issues for export.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_all_comments(&self) -> Result<HashMap<String, Vec<Comment>>> {
let rows = self.conn.query(
"SELECT id, issue_id, author, text, created_at
FROM comments
ORDER BY issue_id ASC, created_at ASC, id ASC",
)?;
let mut map: HashMap<String, Vec<Comment>> = HashMap::new();
for row in &rows {
let comment = comment_from_row(row)?;
map.entry(comment.issue_id.clone())
.or_default()
.push(comment);
}
Ok(map)
}
/// Get all comments attached to exportable issues.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_comments_for_export(&self) -> Result<HashMap<String, Vec<Comment>>> {
let rows = self.conn.query(
"SELECT comments.id, comments.issue_id, comments.author, comments.text,
comments.created_at
FROM comments
INNER JOIN issues ON issues.id = comments.issue_id
WHERE (issues.ephemeral = 0 OR issues.ephemeral IS NULL)
AND issues.id NOT LIKE '%-wisp-%'
ORDER BY comments.issue_id ASC, comments.created_at ASC, comments.id ASC",
)?;
let mut map: HashMap<String, Vec<Comment>> = HashMap::new();
for row in &rows {
let comment = comment_from_row(row)?;
map.entry(comment.issue_id.clone())
.or_default()
.push(comment);
}
Ok(map)
}
/// Get the count of dirty issues (issues modified since last export).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dirty_issue_count(&self) -> Result<usize> {
let row = self.conn.query_row("SELECT COUNT(*) FROM dirty_issues")?;
let count = row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
/// Get the IDs and timestamps of dirty issues.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dirty_issue_metadata(&self) -> Result<Vec<(String, String)>> {
let rows = self
.conn
.query("SELECT issue_id, marked_at FROM dirty_issues ORDER BY issue_id, marked_at")?;
rows.iter()
.enumerate()
.map(|(row_index, row)| {
let issue_id = row.get(0).and_then(SqliteValue::as_text).ok_or_else(|| {
BeadsError::Config(format!("Dirty-issue row {row_index} issue_id was not text"))
})?;
let marked_at = row.get(1).and_then(SqliteValue::as_text).ok_or_else(|| {
BeadsError::Config(format!(
"Dirty-issue row {row_index} marked_at was not text"
))
})?;
Ok((issue_id.to_string(), marked_at.to_string()))
})
.collect()
}
/// Get IDs of all dirty issues (issues modified since last export).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dirty_issue_ids(&self) -> Result<Vec<String>> {
let rows = self
.conn
.query("SELECT issue_id FROM dirty_issues ORDER BY marked_at")?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Clear dirty flags for the given issue IDs and timestamps.
///
/// This is a safe version that only deletes if the timestamp matches,
/// preventing a race condition where a concurrent update during export
/// would otherwise have its dirty flag cleared incorrectly.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn clear_dirty_issues(&self, metadata: &[(String, String)]) -> Result<usize> {
if metadata.is_empty() {
return Ok(0);
}
self.with_connection_write_transaction(|_| self.clear_dirty_issues_in_tx(metadata))
}
pub(crate) fn clear_dirty_issues_in_tx(&self, metadata: &[(String, String)]) -> Result<usize> {
let mut total_deleted = 0;
for (id, marked_at) in metadata {
let count = self.conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ? AND marked_at = ?",
&[
SqliteValue::from(id.as_str()),
SqliteValue::from(marked_at.as_str()),
],
)?;
total_deleted += count;
}
Ok(total_deleted)
}
/// Clear dirty flags for the given issue IDs WITHOUT timestamp validation (Legacy).
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn clear_dirty_issues_legacy(&mut self, issue_ids: &[String]) -> Result<usize> {
if issue_ids.is_empty() {
return Ok(0);
}
self.with_write_transaction(|storage| storage.clear_dirty_issue_ids_in_tx(issue_ids))
}
fn clear_dirty_issue_ids_in_tx(&self, issue_ids: &[String]) -> Result<usize> {
let mut total_deleted = 0;
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
// Delete existing entries row-by-row to avoid fsqlite IN-clause bugs
let mut chunk_deleted = 0;
for id in chunk {
let deleted = self.conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from(id.as_str())],
)?;
chunk_deleted += deleted;
}
total_deleted += chunk_deleted;
}
Ok(total_deleted)
}
/// Clear all dirty flags.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn clear_all_dirty_issues(&mut self) -> Result<usize> {
self.with_write_transaction(Self::clear_all_dirty_issues_in_tx)
}
fn clear_all_dirty_issues_in_tx(storage: &mut Self) -> Result<usize> {
Ok(storage.conn.execute("DELETE FROM dirty_issues")?)
}
// =========================================================================
// Export Hashes (for incremental export)
// =========================================================================
/// Get the stored export hash for an issue.
///
/// Returns the content hash and exported timestamp if the issue has been exported.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_export_hash(&self, issue_id: &str) -> Result<Option<(String, String)>> {
match self.conn.query_row_with_params(
"SELECT content_hash, exported_at FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
) {
Ok(row) => {
let hash = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let exported = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
Ok(Some((hash, exported)))
}
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Set the export hash for an issue after successful export.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn set_export_hash(&mut self, issue_id: &str, content_hash: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
self.with_write_transaction(|storage| {
storage.conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
storage.conn.execute_with_params(
"INSERT INTO export_hashes (issue_id, content_hash, exported_at) VALUES (?, ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(content_hash),
SqliteValue::from(now.as_str()),
],
)?;
Ok(())
})
}
/// Batch set export hashes for multiple issues after successful export.
///
/// More efficient than calling `set_export_hash` in a loop.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn set_export_hashes(&mut self, exports: &[(String, String)]) -> Result<usize> {
if exports.is_empty() {
return Ok(0);
}
self.with_write_transaction(|storage| storage.set_export_hashes_in_tx(exports))
}
/// Clear all export hashes.
///
/// Call this before import to ensure fresh state.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn clear_all_export_hashes(&mut self) -> Result<usize> {
self.with_write_transaction(|storage| {
Ok(storage.conn.execute("DELETE FROM export_hashes")?)
})
}
/// Get issues that need to be exported (dirty issues whose content hash differs from stored export hash).
///
/// This enables incremental export by filtering out issues that haven't actually changed
/// since the last export, even if they were marked dirty.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issues_needing_export(&self, dirty_ids: &[String]) -> Result<Vec<String>> {
const SQLITE_VAR_LIMIT: usize = 900;
if dirty_ids.is_empty() {
return Ok(vec![]);
}
let mut results = Vec::new();
for chunk in dirty_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT i.id FROM issues i
WHERE i.id IN ({})
AND i.deleted_at IS NULL
AND (
i.id NOT IN (SELECT issue_id FROM export_hashes)
OR i.content_hash != (SELECT e.content_hash FROM export_hashes e WHERE e.issue_id = i.id)
)
ORDER BY i.id",
placeholders.join(",")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|s| SqliteValue::from(s.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
results.extend(
rows.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from)),
);
}
results.sort();
Ok(results)
}
/// Get a metadata value by key.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
match self.conn.query_row_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid DESC LIMIT 1",
&[SqliteValue::from(key)],
) {
Ok(row) => Ok(row
.get(0)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(String::from)),
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Read the set of issue IDs that were intentionally purged (hard
/// deleted) but not yet flushed out of the on-disk JSONL (#405).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_purged_ids_pending_export(&self) -> Result<HashSet<String>> {
let Some(raw) = self.get_metadata(PURGED_IDS_PENDING_EXPORT_KEY)? else {
return Ok(HashSet::new());
};
let ids: Vec<String> = serde_json::from_str(&raw).map_err(BeadsError::from)?;
Ok(ids.into_iter().collect())
}
/// Record, inside the purge transaction, that `id` was intentionally
/// removed from the database so the exporter's stale-database guard does
/// not count it as accidental data loss (#405).
fn record_purged_id_pending_export_in_tx(conn: &Connection, id: &str) -> Result<()> {
let existing = match conn.query_row_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid DESC LIMIT 1",
&[SqliteValue::from(PURGED_IDS_PENDING_EXPORT_KEY)],
) {
Ok(row) => row
.get(0)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(String::from),
Err(FrankenError::QueryReturnedNoRows) => None,
Err(error) => return Err(error.into()),
};
let mut ids: Vec<String> = match existing {
Some(raw) => serde_json::from_str(&raw).map_err(BeadsError::from)?,
None => Vec::new(),
};
if !ids.iter().any(|existing_id| existing_id == id) {
ids.push(id.to_string());
ids.sort_unstable();
}
let serialized = serde_json::to_string(&ids).map_err(BeadsError::from)?;
Self::upsert_metadata_key_in_tx(conn, PURGED_IDS_PENDING_EXPORT_KEY, &serialized)
}
/// Clear the purged-pending-export marker inside an export-finalization
/// transaction: once an export has published a JSONL snapshot, the purged
/// IDs are no longer present in it (#405). Deletes the row outright so
/// the metadata table keeps its historical shape (e.g. the sync-merge
/// finalization witness's exact export-metadata row count) whenever no
/// purge is pending.
pub(crate) fn clear_purged_ids_pending_export_in_tx(&self) -> Result<()> {
self.conn.execute_with_params(
"DELETE FROM metadata WHERE key = ?",
&[SqliteValue::from(PURGED_IDS_PENDING_EXPORT_KEY)],
)?;
Ok(())
}
/// Set a metadata value.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn set_metadata(&mut self, key: &str, value: &str) -> Result<()> {
self.with_write_transaction(|storage| {
Self::upsert_metadata_key_in_tx(&storage.conn, key, value)?;
Ok(())
})
}
/// Set a metadata value using an internal write transaction.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub(crate) fn set_metadata_shared(&self, key: &str, value: &str) -> Result<()> {
self.with_connection_write_transaction(|conn| {
Self::upsert_metadata_key_in_tx(conn, key, value)?;
Ok(())
})
}
/// Delete a metadata key.
///
/// # Errors
///
/// Returns an error if the database update fails.
pub fn delete_metadata(&mut self, key: &str) -> Result<bool> {
self.with_write_transaction(|storage| {
let count = storage.conn.execute_with_params(
"DELETE FROM metadata WHERE key = ?",
&[SqliteValue::from(key)],
)?;
Ok(count > 0)
})
}
/// Count issues in the database.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_all_issues(&self) -> Result<usize> {
let count = self
.conn
.query_row("SELECT count(*) FROM issues")?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
Ok(usize::try_from(count).unwrap_or(0))
}
/// Snapshot the events table shape as an immutability witness.
///
/// Returns `(row_count, max_rowid)`. Additive reconciliation captures this
/// at plan time and re-verifies it inside (and at the end of) the apply
/// transaction: import-path upserts never write events, so any change is
/// evidence of a concurrent writer and must roll the apply back.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn events_table_witness(&self) -> Result<(u64, Option<i64>)> {
let count = self
.conn
.query_row("SELECT count(*) FROM events")?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
let max_id = self
.conn
.query_row("SELECT max(id) FROM events")?
.get(0)
.and_then(SqliteValue::as_integer);
Ok((u64::try_from(count).unwrap_or(0), max_id))
}
/// Get all non-ephemeral, non-wisp issue IDs (the exportable population).
///
/// Uses the same filter as the doctor `counts.db_vs_jsonl` check so that
/// DB↔JSONL set comparisons agree on the same population.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_non_ephemeral_issue_ids(&self) -> Result<Vec<String>> {
let rows = self.conn.query(
"SELECT id FROM issues \
WHERE (ephemeral = 0 OR ephemeral IS NULL) AND id NOT LIKE '%-wisp-%' \
ORDER BY id",
)?;
Ok(rows
.iter()
.filter_map(|r| r.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect())
}
/// Delete dependency rows owned by the given issues whose target issue is
/// missing, inside the current write transaction.
///
/// This is the scoped counterpart of the import path's global orphan
/// cleanup: additive reconciliation may only introduce dangling
/// `depends_on_id` references through rows it just wrote, so it must not
/// touch orphan rows owned by any other issue. `external:` dependency
/// targets are never orphans.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn delete_orphan_dependencies_for_issues_in_tx(
&self,
issue_ids: &[String],
) -> Result<usize> {
let mut deleted = 0usize;
for chunk in issue_ids.chunks(IMPORT_DEPENDENCY_CHUNK_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"DELETE FROM dependencies \
WHERE issue_id IN ({}) \
AND depends_on_id NOT LIKE 'external:%' \
AND depends_on_id NOT IN (SELECT id FROM issues)",
placeholders.join(", ")
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
deleted += self.conn.execute_with_params(&sql, ¶ms)?;
}
Ok(deleted)
}
/// Count active project issues using default user-facing visibility.
///
/// Active issues are non-closed issues, including deferred issues, while
/// excluding template issues like the default command/query surfaces.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_active_issues(&self) -> Result<usize> {
self.count_issues_with_filters(&ListFilters {
include_deferred: true,
..ListFilters::default()
})
}
/// Check whether the project has any active issues using default
/// user-facing visibility.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn has_active_issues(&self) -> Result<bool> {
let rows = self.conn.query(
"SELECT 1
FROM issues
WHERE status NOT IN ('closed', 'tombstone')
AND (is_template = 0 OR is_template IS NULL)
LIMIT 1",
)?;
Ok(!rows.is_empty())
}
/// Get full issue details.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issue_details(
&self,
id: &str,
include_comments: bool,
include_events: bool,
event_limit: usize,
) -> Result<Option<IssueDetails>> {
let Some(issue) = self.get_issue(id)? else {
return Ok(None);
};
let relation_presence = self.issue_detail_relation_presence(id)?;
let labels = if relation_presence.has_labels {
self.get_labels(id)?
} else {
vec![]
};
let dependencies = if relation_presence.has_dependencies {
self.get_dependencies_with_metadata(id)?
} else {
vec![]
};
let dependents = if relation_presence.has_dependents {
self.get_dependents_with_metadata(id)?
} else {
vec![]
};
let comments = if include_comments && relation_presence.has_comments {
self.get_comments(id)?
} else {
vec![]
};
let events = if include_events {
get_events(&self.conn, id, event_limit)?
} else {
vec![]
};
let parent = relation_presence.parent;
let rollup = if relation_presence.has_children {
self.derived_rollup(id)?
} else {
None
};
Ok(Some(
IssueDetails {
issue,
labels,
dependencies,
dependents,
comments,
events,
parent,
rollup,
inherited_context: Vec::new(),
acceptance_items: Vec::new(),
}
.with_acceptance_items(),
))
}
/// Derived parent-child subtree rollup (GitHub #384 phase 3).
///
/// Returns `None` when the issue has no local parent-child children.
/// The derived status is the furthest-along non-terminal descendant
/// status: position in the declared `workflow.statuses` order when the
/// project configures one, otherwise a built-in
/// `draft < deferred < open < blocked < in_progress` ladder (statuses
/// outside the order rank lowest; ties resolve to the lexicographically
/// greatest name). When every descendant is terminal
/// (`closed`/`tombstone`) the rollup is `closed`. Traversal uses a
/// visited set, so imported dependency cycles terminate and the issue
/// itself is never counted as its own descendant.
pub fn derived_rollup(&self, issue_id: &str) -> Result<Option<RollupSummary>> {
// Walk the subtree level by level against `idx_dependencies_depends_on_type`
// instead of loading the repository's whole edge map: showing a handful
// of issues in a large project should touch only their own subtrees.
let mut visited: HashSet<String> = HashSet::new();
visited.insert(issue_id.to_string());
let mut frontier: Vec<String> = vec![issue_id.to_string()];
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
while !frontier.is_empty() {
let mut next: Vec<String> = Vec::new();
for chunk in frontier.chunks(SQLITE_VAR_LIMIT) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!(
"SELECT d.issue_id, i.status
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id IN ({placeholders})
AND d.type = 'parent-child'"
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let Some(child) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
// A visited set keeps an imported parent-child cycle from
// looping and stops the issue counting itself.
if !visited.insert(child.to_string()) {
continue;
}
let status = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.trim()
.to_lowercase();
if !status.is_empty() {
*counts.entry(status).or_insert(0) += 1;
}
next.push(child.to_string());
}
}
frontier = next;
}
if counts.is_empty() {
return Ok(None);
}
let workflow_order: Vec<String> = self
.workflow_transition_policy
.statuses
.iter()
.map(|status| status.trim().to_lowercase())
.collect();
let rank = |status: &str| -> i64 {
if workflow_order.is_empty() {
match status {
"draft" => 1,
"deferred" => 2,
"open" => 3,
"blocked" => 4,
"in_progress" => 5,
_ => 0,
}
} else {
workflow_order
.iter()
.position(|candidate| candidate == status)
.map_or(-1, |position| i64::try_from(position).unwrap_or(i64::MAX))
}
};
let status = counts
.keys()
.filter(|status| !matches!(status.as_str(), "closed" | "tombstone"))
.max_by(|left, right| rank(left).cmp(&rank(right)).then_with(|| left.cmp(right)))
.cloned()
.unwrap_or_else(|| "closed".to_string());
Ok(Some(RollupSummary {
status,
descendants: counts,
}))
}
fn issue_detail_relation_presence(&self, id: &str) -> Result<IssueDetailRelationPresence> {
let row = self.conn.query_row_with_params(
"SELECT
EXISTS(SELECT 1 FROM labels WHERE issue_id = ?),
EXISTS(SELECT 1 FROM dependencies WHERE issue_id = ?),
EXISTS(SELECT 1 FROM dependencies WHERE depends_on_id = ?),
EXISTS(SELECT 1 FROM comments WHERE issue_id = ?),
EXISTS(SELECT 1 FROM dependencies
WHERE depends_on_id = ? AND type = 'parent-child'),
(SELECT depends_on_id FROM dependencies
WHERE issue_id = ? AND type = 'parent-child'
ORDER BY rowid DESC LIMIT 1)",
&[
SqliteValue::from(id),
SqliteValue::from(id),
SqliteValue::from(id),
SqliteValue::from(id),
SqliteValue::from(id),
SqliteValue::from(id),
],
)?;
Ok(IssueDetailRelationPresence {
has_labels: row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
has_dependencies: row.get(1).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
has_dependents: row.get(2).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
has_comments: row.get(3).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
has_children: row.get(4).and_then(SqliteValue::as_integer).unwrap_or(0) != 0,
parent: row.get(5).and_then(SqliteValue::as_text).map(String::from),
})
}
fn issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_opt_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.map(str::to_string)
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|v| v as i32)
};
let get_bool = |idx: usize| -> bool {
row.get(idx).and_then(SqliteValue::as_integer).unwrap_or(0) != 0
};
let get_opt_datetime = |idx: usize| -> Result<Option<chrono::DateTime<chrono::Utc>>> {
parse_opt_datetime_value(row.get(idx))
};
Ok(Issue {
id: get_str(0),
content_hash: get_opt_str(1),
title: get_str(2),
description: get_non_empty_str(3),
design: get_non_empty_str(4),
acceptance_criteria: get_non_empty_str(5),
notes: get_non_empty_str(6),
status: parse_status(row.get(7).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(8).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(9).and_then(SqliteValue::as_text)),
assignee: get_non_empty_str(10),
owner: get_non_empty_str(11),
estimated_minutes: get_opt_i32(12),
created_at: parse_datetime_value(row.get(13))?,
created_by: get_non_empty_str(14),
updated_at: parse_datetime_value(row.get(15))?,
closed_at: get_opt_datetime(16)?,
close_reason: get_non_empty_str(17),
closed_by_session: get_non_empty_str(18),
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: get_opt_datetime(19)?,
defer_until: get_opt_datetime(20)?,
external_ref: get_opt_str(21),
source_system: get_non_empty_str(22),
source_repo: get_non_empty_str(23),
deleted_at: get_opt_datetime(24)?,
deleted_by: get_non_empty_str(25),
delete_reason: get_non_empty_str(26),
original_type: get_non_empty_str(27),
compaction_level: get_opt_i32(28),
compacted_at: get_opt_datetime(29)?,
compacted_at_commit: get_opt_str(30),
original_size: get_opt_i32(31),
sender: get_non_empty_str(32),
ephemeral: get_bool(33),
pinned: get_bool(34),
is_template: get_bool(35),
// Position 36 lands after `is_template` in the Full SELECT
// and before `bc.blocked_by` in the BlockedIssue::Full
// variant; the cached_blocked_by_index was bumped to 37
// in lock-step so the projection-specific blocked-by
// accessor still finds the right column.
source_repo_path: get_non_empty_str(36),
agent_context: get_non_empty_str(37),
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn ready_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: get_non_empty_str(2),
design: None,
acceptance_criteria: get_non_empty_str(3),
notes: get_non_empty_str(4),
status: parse_status(row.get(5).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(6).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(7).and_then(SqliteValue::as_text)),
assignee: get_non_empty_str(8),
owner: get_non_empty_str(9),
estimated_minutes: get_opt_i32(10),
created_at: parse_datetime_value(row.get(11))?,
created_by: get_non_empty_str(12),
updated_at: parse_datetime_value(row.get(13))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn blocked_command_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: get_non_empty_str(2),
design: None,
acceptance_criteria: None,
notes: None,
status: parse_status(row.get(3).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(4).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(5).and_then(SqliteValue::as_text)),
assignee: None,
owner: None,
estimated_minutes: None,
created_at: parse_datetime_value(row.get(6))?,
created_by: get_non_empty_str(7),
updated_at: parse_datetime_value(row.get(8))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn stale_command_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: parse_status(row.get(2).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(3).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(4).and_then(SqliteValue::as_text)),
assignee: get_non_empty_str(5),
owner: None,
estimated_minutes: None,
created_at: parse_datetime_value(row.get(6))?,
created_by: None,
updated_at: parse_datetime_value(row.get(7))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn lint_command_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: get_non_empty_str(2),
design: None,
acceptance_criteria: None,
notes: None,
status: parse_status(row.get(3).and_then(SqliteValue::as_text)),
priority: Priority::default(),
issue_type: parse_issue_type(row.get(4).and_then(SqliteValue::as_text)),
assignee: None,
owner: None,
estimated_minutes: None,
created_at: parse_datetime_value(row.get(5))?,
created_by: None,
updated_at: parse_datetime_value(row.get(6))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn search_command_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: get_non_empty_str(2),
design: None,
acceptance_criteria: None,
notes: None,
status: parse_status(row.get(3).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(4).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(5).and_then(SqliteValue::as_text)),
assignee: get_non_empty_str(6),
owner: None,
estimated_minutes: None,
created_at: parse_datetime_value(row.get(7))?,
created_by: None,
updated_at: parse_datetime_value(row.get(8))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn command_summary_issue_from_row(row: &Row) -> Result<Issue> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(Issue {
id: get_str(0),
content_hash: None,
title: get_str(1),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: parse_status(row.get(2).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(3).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(4).and_then(SqliteValue::as_text)),
assignee: None,
owner: None,
estimated_minutes: None,
created_at: parse_datetime_value(row.get(5))?,
created_by: None,
updated_at: parse_datetime_value(row.get(6))?,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
})
}
fn stats_issue_from_row(row: &Row) -> Result<StatsIssueRow> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_non_empty_str = |idx: usize| -> Option<String> {
row.get(idx)
.and_then(SqliteValue::as_text)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
let get_bool = |idx: usize| -> bool {
row.get(idx).and_then(SqliteValue::as_integer).unwrap_or(0) != 0
};
let get_opt_datetime = |idx: usize| -> Result<Option<DateTime<Utc>>> {
parse_opt_datetime_value(row.get(idx))
};
Ok(StatsIssueRow {
id: get_str(0),
status: parse_status(row.get(1).and_then(SqliteValue::as_text)),
priority: Priority(get_opt_i32(2).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(3).and_then(SqliteValue::as_text)),
assignee: get_non_empty_str(4),
created_at: parse_datetime_value(row.get(5))?,
closed_at: get_opt_datetime(6)?,
defer_until: get_opt_datetime(7)?,
ephemeral: get_bool(8),
pinned: get_bool(9),
is_template: get_bool(10),
})
}
fn stats_summary_issue_from_row(row: &Row) -> Result<StatsIssueRow> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
let get_bool = |idx: usize| -> bool {
row.get(idx).and_then(SqliteValue::as_integer).unwrap_or(0) != 0
};
let get_opt_datetime = |idx: usize| -> Result<Option<DateTime<Utc>>> {
parse_opt_datetime_value(row.get(idx))
};
Ok(StatsIssueRow {
id: get_str(0),
status: parse_status(row.get(1).and_then(SqliteValue::as_text)),
priority: Priority::default(),
issue_type: parse_issue_type(row.get(2).and_then(SqliteValue::as_text)),
assignee: None,
created_at: parse_datetime_value(row.get(3))?,
closed_at: get_opt_datetime(4)?,
defer_until: get_opt_datetime(5)?,
ephemeral: get_bool(6),
pinned: get_bool(7),
is_template: get_bool(8),
})
}
fn changelog_issue_from_row(row: &Row) -> Result<ChangelogIssueRow> {
let get_str = |idx: usize| -> String {
row.get(idx)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
};
#[allow(clippy::cast_possible_truncation)]
let get_opt_i32 = |idx: usize| -> Option<i32> {
row.get(idx)
.and_then(SqliteValue::as_integer)
.map(|value| value as i32)
};
Ok(ChangelogIssueRow {
id: get_str(0),
title: get_str(1),
priority: Priority(get_opt_i32(2).unwrap_or_else(|| Priority::default().0)),
issue_type: parse_issue_type(row.get(3).and_then(SqliteValue::as_text)),
created_at: parse_datetime_value(row.get(4))?,
closed_at: parse_opt_datetime_value(row.get(5))?,
})
}
/// Get metadata for all active issues.
///
/// This is used to pre-populate caches for graph traversals, avoiding N+1 queries.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_active_issues_metadata(
&self,
) -> Result<std::collections::HashMap<String, (String, i32, String)>> {
let sql = "SELECT id, title, priority, status FROM issues WHERE status != 'tombstone'";
let rows = self.conn.query(sql)?;
let mut map = std::collections::HashMap::with_capacity(rows.len());
for row in &rows {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let title = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let priority = row.get(2).and_then(SqliteValue::as_integer).map_or(2, |v| {
i32::try_from(v).unwrap_or(if v < 0 { i32::MIN } else { i32::MAX })
});
let status = row
.get(3)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
map.insert(id, (title, priority, status));
}
Ok(map)
}
/// Set metadata (in tx).
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn set_metadata_in_tx(&self, key: &str, value: &str) -> Result<()> {
Self::upsert_metadata_key_in_tx(&self.conn, key, value)
}
/// Clear all export hashes (in tx).
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn clear_all_export_hashes_in_tx(&self) -> Result<usize> {
let count = self.conn.execute("DELETE FROM export_hashes")?;
Ok(count)
}
}
fn finish_issue_mutation_write_probe(
probe_result: std::result::Result<usize, FrankenError>,
rollback_result: std::result::Result<usize, FrankenError>,
) -> Result<()> {
match (probe_result, rollback_result) {
// A zero-row probe update means the target issue was not
// write-addressable through the mutation path the probe was
// attempting — exactly the failure mode this diagnostic was
// built to catch (issue #263). Returning Ok here would turn
// the probe into a false-negative oracle for the storage
// layer's visibility/write-path bug class. Surface it as the
// primary error. If ROLLBACK also fails, retain the zero-row
// diagnostic as the source while explicitly reporting that the
// connection's transaction state is now unknown.
(Ok(0), rollback) => {
let original_error = BeadsError::Database(FrankenError::Internal(
"write probe did not find issue inside mutation transaction".to_string(),
));
Err(SqliteStorage::rollback_result_error(
original_error,
rollback,
"zero-row issue write probe",
))
}
(Ok(_), Ok(_)) => Ok(()),
(Ok(_), Err(rollback_err)) => Err(SqliteStorage::rollback_failure_error(
BeadsError::Config(
"issue write probe succeeded but its rollback cleanup failed".to_string(),
),
&rollback_err,
"successful issue write probe",
)),
(Err(probe_err), Ok(_)) => Err(BeadsError::Database(probe_err)),
(Err(probe_err), Err(rollback_err)) => Err(SqliteStorage::rollback_failure_error(
BeadsError::Database(probe_err),
&rollback_err,
"issue write probe error",
)),
}
}
/// Best-effort removal of an ephemeral temp database and its engine sidecars.
///
/// FrankenSQLite creates sidecars beside any database path it opens: the
/// classic `-wal`/`-shm`/`-journal`, the multi-process namespace files
/// (`-fsqlite-ns-gate`/`-fsqlite-ns-use`), the WAL-cert files
/// (`-wal-cert`/`-wal-cert-head`), and a `.`-separated `.fsqlite-migration-state`
/// file. All of them must go so nothing is left in `TMPDIR` (#299) — reaping
/// only the classic three silently leaked the fsqlite-specific sidecars.
/// Missing files are ignored; this is invoked on teardown and on a failed
/// open, so errors are intentionally swallowed (logged at debug level).
fn remove_temp_db_files(path: &Path) {
let mut targets = vec![path.to_path_buf()];
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
for &suffix in crate::config::db_sidecar_suffixes() {
targets.push(path.with_file_name(format!("{name}{suffix}")));
}
// The migration-state sidecar is `.`-separated and is not part of
// `db_sidecar_suffixes()`, so append it explicitly.
targets.push(path.with_file_name(format!("{name}.fsqlite-migration-state")));
}
for target in targets {
match std::fs::remove_file(&target) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::debug!(path = %target.display(), error = %e, "failed to remove temp db file");
}
}
}
}
/// Report whether an fsqlite namespace sidecar needs an owner-only mode repair.
///
/// fsqlite refuses to open `<db>-fsqlite-ns-gate` / `-fsqlite-ns-use` when the
/// file carries a group/other bit the engine's policy does not accept (see
/// [`sidecar_exposure_is_database_bounded`] and
/// [`engine_accepts_database_bounded_sidecar_exposure`]), and the refusal
/// surfaces as a bare `Database error: unable to open database file:
/// '<sidecar>'`, which reads as database corruption. This preflight is
/// deliberately observational so the lock-free read-only opener can decline
/// instead of chmod.
#[cfg(unix)]
#[derive(Debug)]
struct NamespaceSidecarModeWitness {
path: PathBuf,
identity: (u64, u64),
mode: u32,
/// The sidecar grants no group/other permission beyond the database
/// file's, per the engine's rule; such a sidecar is admitted by
/// FrankenSQLite 0.3.18+ without any repair.
database_bounded: bool,
}
#[cfg(unix)]
impl NamespaceSidecarModeWitness {
/// Whether the engine linked into this binary would refuse the sidecar as
/// it is, so br must repair it before any open.
fn requires_repair(&self) -> bool {
!(self.database_bounded && engine_accepts_database_bounded_sidecar_exposure())
}
}
/// FrankenSQLite's rule for an existing namespace sidecar with group/other
/// bits (frankensqlite `crates/fsqlite-vfs/src/namespace.rs`,
/// `sidecar_exposure_is_acceptable`, commits 947d4aa85 and 64e75a742): the
/// sidecar is acceptable when it grants no group/other permission that the
/// main database file does not already grant, evaluated per principal class.
/// POSIX resolves exactly one class per process and the group class names the
/// file's group, so when the two files share a GID the sidecar's group bits
/// are bounded by the database's group bits and its other bits by the
/// database's other bits; when the GIDs differ a sidecar-group member may fall
/// into either database class, so a sidecar bit is covered only by the
/// database bit in *both* classes. A missing or non-regular database gives no
/// baseline: not bounded. Kept in lockstep with the engine by a parity test
/// that opens such a family through the linked engine.
#[cfg(unix)]
fn sidecar_exposure_is_database_bounded(
sidecar_mode: u32,
sidecar_gid: u32,
db_path: &Path,
) -> bool {
use std::os::unix::fs::MetadataExt;
let exposure = sidecar_mode & 0o077;
if exposure == 0 {
return true;
}
let Ok(database) = std::fs::symlink_metadata(db_path) else {
return false;
};
if !database.file_type().is_file() {
return false;
}
let database_group = database.mode() & 0o070;
let database_other = database.mode() & 0o007;
let (group_cover, other_cover) = if database.gid() == sidecar_gid {
(database_group, database_other)
} else {
let both = (database_group >> 3) & database_other;
(both << 3, both)
};
exposure & !(group_cover | other_cover) == 0
}
/// The first FrankenSQLite release whose namespace validator accepts a
/// database-bounded sidecar exposure and a mount-imposed mask on a sidecar it
/// just created (frankensqlite 947d4aa85 + 64e75a742, after the v0.3.17 tag).
/// Older engines require owner-only (`mode & 0o077 == 0`) sidecars.
#[cfg(unix)]
const ENGINE_DATABASE_BOUNDED_SIDECAR_EXPOSURE_SINCE: (u64, u64, u64) = (0, 3, 18);
/// The locked `fsqlite` version this binary was built against, from
/// `BR_FSQLITE_VERSION` (emitted by `build.rs` from `Cargo.lock`).
#[cfg(unix)]
fn linked_engine_version() -> Option<(u64, u64, u64)> {
parse_engine_version(option_env!("BR_FSQLITE_VERSION")?)
}
#[cfg(unix)]
fn parse_engine_version(version: &str) -> Option<(u64, u64, u64)> {
let core = version.split(['-', '+']).next()?;
let mut parts = core.split('.').map(|part| part.parse::<u64>().ok());
let major = parts.next()??;
let minor = parts.next()??;
let patch = parts.next()??;
Some((major, minor, patch))
}
/// Whether the engine linked into this binary admits a namespace sidecar whose
/// group/other bits are bounded by the database file's (and a mount mask on a
/// sidecar it just created). An unknown engine version is treated as the
/// stricter, older policy so br keeps repairing.
#[cfg(unix)]
fn engine_accepts_database_bounded_sidecar_exposure() -> bool {
linked_engine_version()
.is_some_and(|version| version >= ENGINE_DATABASE_BOUNDED_SIDECAR_EXPOSURE_SINCE)
}
/// Human-readable statement of the linked engine's sidecar permission rule,
/// for refusal messages.
#[cfg(unix)]
fn engine_sidecar_rule_text() -> String {
let version = option_env!("BR_FSQLITE_VERSION").unwrap_or("unknown");
if engine_accepts_database_bounded_sidecar_exposure() {
format!(
"FrankenSQLite {version} (linked into this br) accepts a namespace lock sidecar only when it is owner-only (0600) or grants no group/other permission beyond the database file's"
)
} else {
format!(
"FrankenSQLite {version} (linked into this br) accepts a namespace lock sidecar only when it is owner-only (0600); FrankenSQLite 0.3.18+ also accepts a sidecar that grants no group/other permission beyond the database file's, which is what a permission-less mount reports for every file, so a br release built on that engine works on such a mount"
)
}
}
#[cfg(unix)]
fn namespace_sidecar_mode_repair_witnesses(
db_path: &Path,
) -> Result<Vec<NamespaceSidecarModeWitness>> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mut witnesses = Vec::new();
for suffix in crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES {
let sidecar = database_sidecar_path(db_path, suffix);
let metadata = match std::fs::symlink_metadata(&sidecar) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(BeadsError::Io(error)),
};
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing unsafe fsqlite namespace sidecar {}: expected a regular file, not a symlink or special file",
sidecar.display()
),
});
}
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
let database_bounded =
sidecar_exposure_is_database_bounded(mode, metadata.gid(), db_path);
witnesses.push(NamespaceSidecarModeWitness {
path: sidecar,
identity: (metadata.dev(), metadata.ino()),
mode,
database_bounded,
});
}
}
Ok(witnesses)
}
/// Effective uid of this process, for sidecar ownership classification.
#[cfg(unix)]
fn effective_uid() -> u32 {
rustix::process::geteuid().as_raw()
}
/// The actionable refusal for a namespace sidecar whose group/other bits are
/// imposed by the filesystem rather than by anyone's chmod (GitHub #491).
///
/// FrankenSQLite admits `<db>-fsqlite-ns-gate` / `-fsqlite-ns-use` only when
/// they are owner-only (0600). A mount that does not persist POSIX permission
/// bits — a Windows drive under WSL (`/mnt/<drive>`) mounted without the
/// `metadata` option, FAT/exFAT volumes, some network filesystems — reports a
/// fixed mask such as 0777 for every file, ignores the 0600 creation mode and
/// every chmod, and so can never satisfy the engine. Nothing br could do to the
/// sidecar changes that, so the refusal names the limitation and the remedy
/// instead of the bare `unable to open database file` the engine produces.
#[cfg(unix)]
fn permissionless_filesystem_error(
sidecar: &Path,
observed_mode: u32,
evidence: &str,
database_bounded: bool,
) -> BeadsError {
let rule = engine_sidecar_rule_text();
let scope = if database_bounded {
"The sidecar grants nothing beyond the database file itself (the mount reports the same mask for every file), so nothing here is a repairable permission problem."
} else {
"The sidecar also grants group/other permission that the database file does not, and the filesystem will not let br take it away."
};
BeadsError::Config(format!(
"fsqlite namespace sidecar {} reports mode {:04o} {evidence}: the filesystem holding this database does not persist POSIX permission bits. {rule}. {scope} This is typical of a Windows drive under WSL (/mnt/<drive>) mounted without the `metadata` option, of FAT/exFAT volumes, and of some network mounts. Remedy: on WSL add `[automount]` with `options = \"metadata\"` to /etc/wsl.conf and run `wsl --shutdown`, or keep the .beads database on the Linux filesystem (for example under $HOME); a Windows build of br can write a database on a Windows drive from Windows",
sidecar.display(),
observed_mode & 0o7777,
))
}
/// Namespace sidecar suffixes that do not exist beside `db_path` right now.
///
/// Recorded immediately before an engine open so a later `CannotOpen` naming
/// one of them can be attributed to the engine's own 0600 creation rather than
/// to a pre-existing file (see [`explain_engine_open_error`]).
#[cfg(unix)]
fn absent_namespace_sidecar_suffixes(db_path: &Path) -> Vec<&'static str> {
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES
.iter()
.copied()
.filter(|suffix| {
matches!(
std::fs::symlink_metadata(database_sidecar_path(db_path, suffix)),
Err(ref error) if error.kind() == std::io::ErrorKind::NotFound
)
})
.collect()
}
#[cfg(not(unix))]
fn absent_namespace_sidecar_suffixes(_db_path: &Path) -> Vec<&'static str> {
Vec::new()
}
/// Which namespace sidecar, if any, an engine `CannotOpen` path names.
#[cfg(unix)]
fn namespace_sidecar_suffix_of(db_path: &Path, failed_path: &Path) -> Option<&'static str> {
let db_name = db_path.file_name()?.to_str()?;
let failed_name = failed_path.file_name()?.to_str()?;
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES
.iter()
.copied()
.find(|suffix| failed_name == format!("{db_name}{suffix}"))
}
/// Replace the engine's bare `unable to open database file: '<sidecar>'` with
/// the reason the engine refused the namespace sidecar (GitHub #403, #491).
///
/// FrankenSQLite reports every sidecar refusal — wrong owner, extra hard
/// links, group/other permission bits — as `CannotOpen` naming the sidecar,
/// which reads as database corruption. The sidecar is inspected only after the
/// failure and nothing is changed; when the inspection does not explain the
/// refusal the original engine error is returned unchanged.
fn explain_engine_open_error(
db_path: &Path,
absent_sidecars_before_open: &[&'static str],
error: fsqlite_error::FrankenError,
) -> BeadsError {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if let fsqlite_error::FrankenError::CannotOpen { path } = &error
&& let Some(suffix) = namespace_sidecar_suffix_of(db_path, path)
&& let Ok(metadata) = std::fs::symlink_metadata(path)
&& metadata.is_file()
&& !metadata.file_type().is_symlink()
{
let mode = metadata.permissions().mode();
if metadata.uid() != effective_uid() {
return BeadsError::Config(format!(
"fsqlite refused its namespace sidecar {} because it is owned by uid {}, not the current user (uid {}); the database itself is fine. Run br as the owner, or have the owner remove the stale `{suffix}` sidecar files (they are regenerable lock files)",
path.display(),
metadata.uid(),
effective_uid(),
));
}
if metadata.nlink() != 1 {
return BeadsError::Config(format!(
"fsqlite refused its namespace sidecar {} because it has {} hard links and the engine requires exactly one; the database itself is fine. Remove the extra links",
path.display(),
metadata.nlink(),
));
}
if mode & 0o077 != 0 {
let database_bounded =
sidecar_exposure_is_database_bounded(mode, metadata.gid(), db_path);
if absent_sidecars_before_open.contains(&suffix) {
// The engine created this file with mode 0600 during the
// open that just failed; the bits it reads back are the
// mount's, not anyone's chmod.
return permissionless_filesystem_error(
path,
mode,
"immediately after fsqlite created it with mode 0600",
database_bounded,
);
}
let database_missing = !std::fs::symlink_metadata(db_path)
.is_ok_and(|database| database.file_type().is_file());
if database_missing {
return BeadsError::Config(format!(
"fsqlite refused its namespace sidecar {} because it has mode {:04o} and there is no regular database file beside it to bound that mode against. The sidecar is an orphaned, regenerable lock file; move it out of the way (or restore the database) and retry",
path.display(),
mode & 0o7777,
));
}
let rule = engine_sidecar_rule_text();
let situation = if database_bounded {
"The sidecar grants nothing beyond the database file itself, so this is either an older engine that still requires owner-only sidecars or a filesystem that will not hold the repaired mode".to_string()
} else {
let database_mode = std::fs::symlink_metadata(db_path)
.map(|database| database.permissions().mode() & 0o7777)
.unwrap_or(0);
format!(
"The sidecar is looser than the database file (mode {database_mode:04o}), which no engine version accepts"
)
};
return BeadsError::Config(format!(
"fsqlite refused its namespace sidecar {} because it has mode {:04o}; the database itself is fine. {rule}. {situation}. br repairs the mode automatically when it opens the database under its database-family authority; otherwise run `br doctor --repair` or `chmod 0600 {}`",
path.display(),
mode & 0o7777,
path.display(),
));
}
}
}
#[cfg(not(unix))]
{
let _ = (db_path, absent_sidecars_before_open);
}
BeadsError::Database(error)
}
fn namespace_sidecar_mode_repair_required(db_path: &Path) -> Result<bool> {
#[cfg(unix)]
{
Ok(namespace_sidecar_mode_repair_witnesses(db_path)?
.iter()
.any(NamespaceSidecarModeWitness::requires_repair))
}
#[cfg(not(unix))]
{
let _ = db_path;
Ok(false)
}
}
fn database_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
let mut sidecar = db_path.as_os_str().to_os_string();
sidecar.push(suffix);
PathBuf::from(sidecar)
}
fn future_schema_error(version: u32, current_schema_version: u32) -> BeadsError {
BeadsError::Config(format!(
"Database schema version {version} is newer than this br binary supports \
({current_schema_version}); refusing to modify or downgrade it"
))
}
/// A regular schema-family file held open through byte inspection.
///
/// Unix opens are no-follow and retain the inode. Windows opens retain the
/// delete-denying handle returned by the pinned-path authority surface, then
/// compare a second guarded path open before the result is trusted. This keeps
/// a path replacement from turning a header or WAL read into evidence about a
/// different filesystem object.
#[derive(Debug)]
struct StableSchemaSource {
file: std::fs::File,
initial_len: u64,
#[cfg(unix)]
identity: (u64, u64),
#[cfg(windows)]
identity: crate::sync::path::JsonlFileIdentity,
}
impl StableSchemaSource {
fn open_optional(path: &Path, description: &str) -> Result<Option<Self>> {
let initial_metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(BeadsError::Io(error)),
};
if initial_metadata.file_type().is_symlink() || !initial_metadata.is_file() {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because the {description} path is not a regular file"
),
});
}
#[cfg(windows)]
let (file, identity) = {
let source = crate::sync::path::open_regular_authority_source(path)?.ok_or_else(|| {
BeadsError::SyncConflict {
message: format!(
"{description} disappeared before its guarded schema-preflight handle could be opened"
),
}
})?;
let identity = source.identity();
(source.into_file(), identity)
};
#[cfg(not(windows))]
let file = {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
options.open(path).map_err(|error| BeadsError::SyncConflict {
message: format!(
"{description} changed before its no-follow schema-preflight handle could be opened: {error}"
),
})?
};
let handle_metadata = file.metadata()?;
if !handle_metadata.is_file() || handle_metadata.len() != initial_metadata.len() {
return Err(BeadsError::SyncConflict {
message: format!(
"{description} changed identity or length during schema preflight"
),
});
}
#[cfg(unix)]
let identity = {
use std::os::unix::fs::MetadataExt;
let identity = (initial_metadata.dev(), initial_metadata.ino());
if (handle_metadata.dev(), handle_metadata.ino()) != identity {
return Err(BeadsError::SyncConflict {
message: format!("{description} changed identity during schema preflight"),
});
}
identity
};
let source = Self {
file,
initial_len: initial_metadata.len(),
#[cfg(unix)]
identity,
#[cfg(windows)]
identity,
};
source.verify_path(path, description)?;
Ok(Some(source))
}
fn verify_path(&self, path: &Path, description: &str) -> Result<()> {
let handle_metadata = self.file.metadata()?;
let path_metadata =
std::fs::symlink_metadata(path).map_err(|error| BeadsError::SyncConflict {
message: format!(
"{description} changed while its schema preflight was being verified: {error}"
),
})?;
if !handle_metadata.is_file()
|| handle_metadata.len() != self.initial_len
|| path_metadata.file_type().is_symlink()
|| !path_metadata.is_file()
|| path_metadata.len() != self.initial_len
{
return Err(BeadsError::SyncConflict {
message: format!(
"{description} changed identity or length while its schema preflight was being verified"
),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if (handle_metadata.dev(), handle_metadata.ino()) != self.identity
|| (path_metadata.dev(), path_metadata.ino()) != self.identity
{
return Err(BeadsError::SyncConflict {
message: format!(
"{description} changed identity while its schema preflight was being verified"
),
});
}
}
#[cfg(windows)]
{
let current_guard = crate::sync::path::open_regular_authority_source(path)?
.ok_or_else(|| BeadsError::SyncConflict {
message: format!(
"{description} disappeared while its schema preflight was being verified"
),
})?;
if current_guard.identity() != self.identity {
return Err(BeadsError::SyncConflict {
message: format!(
"{description} changed identity while its schema preflight was being verified"
),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WalSchemaPreflight {
committed_user_version: Option<u32>,
has_committed_frames: bool,
}
fn wal_checksum(bytes: &[u8], mut s1: u32, mut s2: u32, big_endian_words: bool) -> (u32, u32) {
let (chunks, remainder) = bytes.as_chunks::<8>();
debug_assert!(remainder.is_empty());
for chunk in chunks {
let first = [chunk[0], chunk[1], chunk[2], chunk[3]];
let second = [chunk[4], chunk[5], chunk[6], chunk[7]];
let x0 = if big_endian_words {
u32::from_be_bytes(first)
} else {
u32::from_le_bytes(first)
};
let x1 = if big_endian_words {
u32::from_be_bytes(second)
} else {
u32::from_le_bytes(second)
};
s1 = s1.wrapping_add(x0).wrapping_add(s2);
s2 = s2.wrapping_add(x1).wrapping_add(s1);
}
(s1, s2)
}
/// Parse the effective schema stamp from a SQLite WAL without opening any
/// engine surface. Recovery follows SQLite's WAL scan rule: complete frames
/// are accepted while salts and rolling checksums remain valid, and recovery
/// stops at the first invalid frame or an incomplete crash tail. Valid frames
/// after the last commit are ignored. Header and valid page-one corruption are
/// still hard failures because accepting either would manufacture schema
/// authority that SQLite itself does not provide.
// Keep the complete fail-closed recovery scan together so its retained-handle
// verification and schema-authority transitions remain auditable in order.
#[allow(clippy::too_many_lines)]
fn sqlite_wal_schema_preflight(db_path: &Path) -> Result<WalSchemaPreflight> {
let wal_path = database_sidecar_path(db_path, "-wal");
let Some(mut wal_source) = StableSchemaSource::open_optional(&wal_path, "WAL")? else {
return Ok(WalSchemaPreflight {
committed_user_version: None,
has_committed_frames: false,
});
};
let wal_len = wal_source.initial_len;
if wal_len == 0 {
wal_source.verify_path(&wal_path, "WAL")?;
return Ok(WalSchemaPreflight {
committed_user_version: None,
has_committed_frames: false,
});
}
if wal_len < 32 {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because the {}-byte WAL header is truncated",
wal_len
),
});
}
let mut header = [0_u8; 32];
wal_source.file.read_exact(&mut header)?;
let magic = u32::from_be_bytes(header[..4].try_into().unwrap_or([0; 4]));
if !matches!(magic, 0x377f_0682 | 0x377f_0683) {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because WAL magic {magic:#010x} is invalid"
),
});
}
let format_version = u32::from_be_bytes(header[4..8].try_into().unwrap_or([0; 4]));
if format_version != 3_007_000 {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because WAL format version {format_version} is unsupported"
),
});
}
let page_size = u32::from_be_bytes(header[8..12].try_into().unwrap_or([0; 4]));
if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because WAL page size {page_size} is invalid"
),
});
}
let expected_checksum = wal_checksum(&header[..24], 0, 0, magic == 0x377f_0683);
let stored_checksum = (
u32::from_be_bytes(header[24..28].try_into().unwrap_or([0; 4])),
u32::from_be_bytes(header[28..32].try_into().unwrap_or([0; 4])),
);
if stored_checksum != expected_checksum {
return Err(BeadsError::SyncConflict {
message: "Refusing schema preflight because the WAL header checksum is invalid"
.to_string(),
});
}
let frame_size = u64::from(page_size) + 24;
let frame_bytes = wal_len - 32;
let page_size_usize = usize::try_from(page_size).map_err(|_| BeadsError::SyncConflict {
message: "WAL page size does not fit this platform".to_string(),
})?;
let frame_size_usize = page_size_usize + 24;
// SQLite recovery ignores an incomplete final frame. Old bytes after a WAL
// restart are likewise ignored once their salts/checksum stop matching.
let frame_count = frame_bytes / frame_size;
let header_salts = (
u32::from_be_bytes(header[16..20].try_into().unwrap_or([0; 4])),
u32::from_be_bytes(header[20..24].try_into().unwrap_or([0; 4])),
);
let big_endian_words = magic == 0x377f_0683;
let mut running_checksum = expected_checksum;
let mut pending_page_one_version = None;
let mut committed_page_one_version = None;
let mut has_committed_frames = false;
let mut frame = vec![0_u8; frame_size_usize];
let mut last_inspected_frame_offset = None;
for frame_index in 0..frame_count {
wal_source.file.read_exact(&mut frame)?;
last_inspected_frame_offset = Some(32 + frame_index * frame_size);
let frame_salts = (
u32::from_be_bytes(frame[8..12].try_into().unwrap_or([0; 4])),
u32::from_be_bytes(frame[12..16].try_into().unwrap_or([0; 4])),
);
if frame_salts != header_salts {
break;
}
let checksum_after_header = wal_checksum(
&frame[..8],
running_checksum.0,
running_checksum.1,
big_endian_words,
);
let expected_frame_checksum = wal_checksum(
&frame[24..],
checksum_after_header.0,
checksum_after_header.1,
big_endian_words,
);
let stored_frame_checksum = (
u32::from_be_bytes(frame[16..20].try_into().unwrap_or([0; 4])),
u32::from_be_bytes(frame[20..24].try_into().unwrap_or([0; 4])),
);
if stored_frame_checksum != expected_frame_checksum {
break;
}
running_checksum = expected_frame_checksum;
let page_number = u32::from_be_bytes(frame[..4].try_into().unwrap_or([0; 4]));
if page_number == 0 {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because valid WAL frame {frame_index} has page number zero"
),
});
}
let database_size = u32::from_be_bytes(frame[4..8].try_into().unwrap_or([0; 4]));
if database_size != 0 && database_size < page_number {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because valid commit frame {frame_index} records database size {database_size} below its page number {page_number}"
),
});
}
if page_number == 1 {
let page = &frame[24..];
if &page[..16] != b"SQLite format 3\0" {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because WAL page-one frame {frame_index} has an invalid database header"
),
});
}
let encoded_page_size = u16::from_be_bytes(page[16..18].try_into().unwrap_or([0; 2]));
let page_one_size = if encoded_page_size == 1 {
65_536
} else {
u32::from(encoded_page_size)
};
if page_one_size != page_size {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing schema preflight because WAL page-one frame {frame_index} disagrees with the WAL page size"
),
});
}
pending_page_one_version = Some(u32::from_be_bytes(
page[60..64].try_into().unwrap_or([0; 4]),
));
}
if database_size != 0 {
has_committed_frames = true;
committed_page_one_version = pending_page_one_version;
}
}
// Re-read the header and the scan boundary on the same retained handle.
// The boundary is either the last complete valid frame or the first stale
// frame that ended recovery, which catches normal same-length WAL reuse.
wal_source.file.seek(SeekFrom::Start(0))?;
let mut final_header = [0_u8; 32];
wal_source.file.read_exact(&mut final_header)?;
if final_header != header {
return Err(BeadsError::SyncConflict {
message: "WAL header changed while its schema preflight was being verified".to_string(),
});
}
if let Some(frame_offset) = last_inspected_frame_offset {
let expected_boundary = frame.clone();
wal_source.file.seek(SeekFrom::Start(frame_offset))?;
wal_source.file.read_exact(&mut frame)?;
if frame != expected_boundary {
return Err(BeadsError::SyncConflict {
message:
"WAL recovery boundary changed while its schema preflight was being verified"
.to_string(),
});
}
}
wal_source.verify_path(&wal_path, "WAL")?;
Ok(WalSchemaPreflight {
committed_user_version: committed_page_one_version,
has_committed_frames,
})
}
fn preflight_effective_schema_before_writable_open(db_path: &Path) -> Result<()> {
let current_schema_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap_or(0);
let header_version = checked_database_header_user_version(db_path)?;
if let Some(header_version) = header_version
&& header_version > current_schema_version
{
return Err(future_schema_error(header_version, current_schema_version));
}
let wal_preflight = sqlite_wal_schema_preflight(db_path)?;
let Some(header_version) = header_version else {
if wal_preflight.has_committed_frames {
return Err(BeadsError::SyncConflict {
message: "Refusing writable database open because committed WAL frames exist without a stable readable main-database header"
.to_string(),
});
}
return Ok(());
};
let effective_version = wal_preflight
.committed_user_version
.unwrap_or(header_version);
if effective_version > current_schema_version {
return Err(future_schema_error(
effective_version,
current_schema_version,
));
}
Ok(())
}
/// Prove that chmod cannot precede an effective future schema.
///
/// The main-header and WAL witnesses are read through stable no-follow
/// handles. WAL page one is resolved with SQLite's recovery scan rules, so a
/// valid committed version overrides the main header while reused, partial, or
/// uncommitted tail bytes do not authorize or block a repair.
fn verify_namespace_healing_schema_precondition(db_path: &Path) -> Result<()> {
let current_schema_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap_or(0);
let header_version = checked_database_header_user_version(db_path)?.ok_or_else(|| {
BeadsError::SyncConflict {
message: "Refusing fsqlite namespace sidecar repair because the database header does not prove a readable schema version"
.to_string(),
}
})?;
if header_version > current_schema_version {
return Err(future_schema_error(header_version, current_schema_version));
}
let wal_preflight = sqlite_wal_schema_preflight(db_path)?;
let effective_version = wal_preflight
.committed_user_version
.unwrap_or(header_version);
if effective_version > current_schema_version {
return Err(future_schema_error(
effective_version,
current_schema_version,
));
}
Ok(())
}
fn verify_namespace_healing_authority(
db_path: &Path,
authority: &crate::sync::DatabaseFamilyWriteLock,
) -> Result<()> {
let planned_authority = crate::sync::database_write_authority_sha256(db_path)?;
if planned_authority != authority.authority_path_sha256() {
return Err(BeadsError::SyncConflict {
message:
"Fsqlite namespace sidecar repair path does not match the held database-family authority"
.to_string(),
});
}
authority.verify_database_authority()?;
if authority.database_target_authority_state()?
!= crate::sync::DatabaseTargetAuthorityState::Held
{
return Err(BeadsError::SyncConflict {
message:
"Fsqlite namespace sidecar repair requires a bound live database inode authority"
.to_string(),
});
}
Ok(())
}
/// `fchmod` the no-follow repair handle to `mode`.
///
/// Test builds can emulate a permission-less mount, which returns success and
/// leaves the observed bits unchanged; the caller re-reads the handle metadata
/// afterwards and treats that outcome as the filesystem limitation it is.
#[cfg(unix)]
fn apply_namespace_sidecar_mode_repair(
sidecar_file: &std::fs::File,
mode: u32,
) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
#[cfg(test)]
if IGNORE_NAMESPACE_SIDECAR_CHMOD.with(std::cell::Cell::get) {
return Ok(());
}
sidecar_file.set_permissions(std::fs::Permissions::from_mode(mode))
}
#[cfg(all(test, unix))]
fn maybe_swap_namespace_sidecar_after_open_for_test(sidecar: &Path) -> Result<()> {
let victim = SWAP_NAMESPACE_SIDECAR_AFTER_OPEN.with(|pending| pending.borrow_mut().take());
let Some(victim) = victim else {
return Ok(());
};
let retained = database_sidecar_path(sidecar, ".test-retained-after-open-symlink-swap");
std::fs::rename(sidecar, &retained)?;
std::os::unix::fs::symlink(victim, sidecar)?;
Ok(())
}
#[cfg(not(all(test, unix)))]
// Match the test-only hook's fallible signature so the security-sensitive call
// site is identical in test and production configurations.
#[allow(clippy::unnecessary_wraps)]
fn maybe_swap_namespace_sidecar_after_open_for_test(_sidecar: &Path) -> Result<()> {
Ok(())
}
/// Repair namespace sidecar modes only under the exact live database-family
/// authority. Every chmod targets a no-follow file handle whose inode is
/// matched to both the pre-open and immediate pre-chmod path witnesses.
// Keep the authority, inode, schema, chmod, and postcondition checks in one
// ordered fail-closed procedure; splitting them would obscure that sequence.
#[allow(clippy::too_many_lines)]
fn heal_namespace_sidecar_modes_under_authority(
db_path: &Path,
authority: &crate::sync::DatabaseFamilyWriteLock,
) -> Result<()> {
verify_namespace_healing_authority(db_path, authority)?;
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
// A sidecar the linked engine already admits as it is (group/other
// bits bounded by the database file's, FrankenSQLite 0.3.18+) needs
// no repair; touching it would only add a mutation the verdict path
// does not need.
let repair_witnesses: Vec<NamespaceSidecarModeWitness> =
namespace_sidecar_mode_repair_witnesses(db_path)?
.into_iter()
.filter(NamespaceSidecarModeWitness::requires_repair)
.collect();
if repair_witnesses.is_empty() {
return Ok(());
}
verify_namespace_healing_schema_precondition(db_path)?;
// Revalidate the complete witnessed set immediately before the first
// chmod. A later invalid family member must not be discovered only
// after an earlier member has already been changed.
for witness in &repair_witnesses {
let metadata = std::fs::symlink_metadata(&witness.path)?;
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| (metadata.dev(), metadata.ino()) != witness.identity
|| metadata.permissions().mode() != witness.mode
{
return Err(BeadsError::SyncConflict {
message: format!(
"Fsqlite namespace sidecar {} changed after the family preflight",
witness.path.display()
),
});
}
}
for witness in repair_witnesses {
let database_bounded = witness.database_bounded;
let sidecar = witness.path;
let initial_metadata = match std::fs::symlink_metadata(&sidecar) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(BeadsError::Io(error)),
};
if initial_metadata.file_type().is_symlink() || !initial_metadata.is_file() {
return Err(BeadsError::SyncConflict {
message: format!(
"Refusing unsafe fsqlite namespace sidecar {}: expected a regular file, not a symlink or special file",
sidecar.display()
),
});
}
let observed_mode = initial_metadata.permissions().mode();
if (initial_metadata.dev(), initial_metadata.ino()) != witness.identity
|| observed_mode != witness.mode
{
return Err(BeadsError::SyncConflict {
message: format!(
"Fsqlite namespace sidecar {} changed identity after the family preflight",
sidecar.display()
),
});
}
if observed_mode.is_multiple_of(0o100) {
continue;
}
let mut options = std::fs::OpenOptions::new();
options.read(true);
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
let sidecar_file = options.open(&sidecar).map_err(|error| {
BeadsError::SyncConflict {
message: format!(
"Fsqlite namespace sidecar {} changed before a no-follow repair handle could be opened: {error}",
sidecar.display()
),
}
})?;
maybe_swap_namespace_sidecar_after_open_for_test(&sidecar)?;
let handle_metadata = sidecar_file.metadata()?;
let pre_chmod_metadata = std::fs::symlink_metadata(&sidecar)?;
let initial_identity = witness.identity;
let handle_identity = (handle_metadata.dev(), handle_metadata.ino());
let pre_chmod_identity = (pre_chmod_metadata.dev(), pre_chmod_metadata.ino());
if !handle_metadata.is_file()
|| pre_chmod_metadata.file_type().is_symlink()
|| !pre_chmod_metadata.is_file()
|| initial_identity != handle_identity
|| handle_identity != pre_chmod_identity
{
return Err(BeadsError::SyncConflict {
message: format!(
"Fsqlite namespace sidecar {} changed identity before its mode repair",
sidecar.display()
),
});
}
verify_namespace_healing_authority(db_path, authority)?;
verify_namespace_healing_schema_precondition(db_path)?;
let repaired_mode = handle_metadata.permissions().mode() & !0o077;
if let Err(error) = apply_namespace_sidecar_mode_repair(&sidecar_file, repaired_mode) {
// The chmod attempt is the ownership test. A refused chmod on
// a sidecar this user owns is the filesystem declining to
// store permission bits at all (GH #491), not a policy
// violation br can repair.
if handle_metadata.uid() == effective_uid() {
return Err(permissionless_filesystem_error(
&sidecar,
observed_mode,
&format!(
"and the owner's chmod to {:04o} was refused ({error})",
repaired_mode & 0o7777
),
database_bounded,
));
}
return Err(BeadsError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"fsqlite namespace sidecar {} has mode {:04o} and is owned by uid {}, not the current user (uid {}); FrankenSQLite admits only sidecars owned by the user running br, and the authority-gated handle repair failed ({error}). Have the owner run `chmod 0600` on it, or remove the stale sidecar files (they are regenerable lock files) as the owner",
sidecar.display(),
observed_mode & 0o7777,
handle_metadata.uid(),
effective_uid(),
),
)));
}
let repaired_metadata = sidecar_file.metadata()?;
let final_path_metadata = std::fs::symlink_metadata(&sidecar)?;
if final_path_metadata.file_type().is_symlink()
|| !final_path_metadata.is_file()
|| (repaired_metadata.dev(), repaired_metadata.ino()) != handle_identity
|| (final_path_metadata.dev(), final_path_metadata.ino()) != handle_identity
{
return Err(BeadsError::SyncConflict {
message: format!(
"Fsqlite namespace sidecar {} changed identity while its mode repair was being verified",
sidecar.display()
),
});
}
let repaired_observed_mode = repaired_metadata.permissions().mode();
if repaired_observed_mode & 0o077 != 0 {
// fchmod on the still-identical inode returned success but
// the group/other bits are unchanged: the mount reports a
// fixed mask (WSL drvfs without `metadata`, FAT/exFAT) and
// silently ignores chmod (GH #491).
return Err(permissionless_filesystem_error(
&sidecar,
repaired_observed_mode,
&format!(
"after the owner's chmod to {:04o} returned success",
repaired_mode & 0o7777
),
database_bounded,
));
}
verify_namespace_healing_authority(db_path, authority)?;
tracing::debug!(
sidecar = %sidecar.display(),
observed_mode = format!("{:04o}", observed_mode & 0o7777),
repaired_mode = format!("{:04o}", repaired_mode & 0o7777),
"repaired over-permissive fsqlite namespace sidecar mode",
);
}
}
#[cfg(not(unix))]
{
let _ = db_path;
}
Ok(())
}
fn checked_database_header_user_version(path: &Path) -> Result<Option<u32>> {
if path == Path::new(":memory:") {
return Ok(None);
}
let Some(mut source) = StableSchemaSource::open_optional(path, "database")? else {
return Ok(None);
};
if source.initial_len < 100 {
source.verify_path(path, "database")?;
return Ok(None);
}
let mut header = [0_u8; 100];
source.file.read_exact(&mut header)?;
source.file.seek(SeekFrom::Start(0))?;
let mut verified_header = [0_u8; 100];
source.file.read_exact(&mut verified_header)?;
if verified_header != header {
return Err(BeadsError::SyncConflict {
message: "Database header changed while its schema preflight was being verified"
.to_string(),
});
}
source.verify_path(path, "database")?;
if &header[..16] != b"SQLite format 3\0" {
return Ok(None);
}
Ok(Some(u32::from_be_bytes([
header[60], header[61], header[62], header[63],
])))
}
/// Best-effort, engine-free read of the checkpointed schema `user_version`
/// from the SQLite file header.
///
/// Returns `None` when the path is absent, is not a SQLite database, is too
/// short to carry a header, or cannot be read — this never surfaces an error,
/// so it is safe for the `br doctor health` sub-200 ms tripwire (#464), which
/// must not open the engine or fail. Reports the *checkpointed* header value;
/// callers that need the WAL-resident effective version use
/// [`effective_database_user_version`] instead.
pub(crate) fn database_header_user_version(path: &Path) -> Option<u32> {
checked_database_header_user_version(path).ok().flatten()
}
/// Read `PRAGMA user_version` through an open connection (issue #373).
///
/// Unlike raw file-header inspection, which sees only checkpointed bytes,
/// this observes the *effective* schema version — including a value that lives
/// only in an uncheckpointed WAL written by another process. Relying on the
/// header alone can make a current database read as stale (header still at the
/// old version, WAL holding the new one), causing the schema to be re-applied
/// over live data. Prefer this and fall back to the header peek only when the
/// pragma cannot be read.
fn connection_user_version(conn: &Connection) -> Option<u32> {
let row = conn.query_row("PRAGMA user_version").ok()?;
row.get(0)
.and_then(SqliteValue::as_integer)
.and_then(|v| u32::try_from(v).ok())
}
/// Byte range of the WAL-index reader-mark array (`WalCkptInfo.aReadMark`,
/// five native-endian u32 values at `-shm` offsets 100..120).
///
/// A WAL reader registers the snapshot it reads at in this array as part of
/// the read-lock protocol; stock SQLite does the same. A read-only open
/// therefore cannot promise to leave these 20 bytes alone without giving up
/// WAL-correct reads (an uncheckpointed WAL may hold the only copy of the
/// current state, see #373). Every other byte of the database family is
/// covered by the read-only contract (GitHub #476).
pub(crate) const SHM_READ_MARK_RANGE: std::ops::Range<usize> = 100..120;
/// Suffixes that make up a SQLite database family on disk; `""` is the main
/// file.
const DATABASE_FAMILY_SUFFIXES: [&str; 4] = ["", "-wal", "-shm", "-journal"];
/// Largest main database file the read-only-open probe will copy. Larger
/// databases skip the probe so `br doctor` stays cheap.
const READ_ONLY_OPEN_PROBE_MAX_BYTES: u64 = 64 * 1024 * 1024;
/// Bytes of every database-family artifact keyed by suffix; a missing
/// artifact is `None`.
pub(crate) type DatabaseFamilySnapshot = BTreeMap<String, Option<Vec<u8>>>;
fn database_family_member_path(db_path: &Path, suffix: &str) -> PathBuf {
if suffix.is_empty() {
db_path.to_path_buf()
} else {
PathBuf::from(format!("{}{suffix}", db_path.to_string_lossy()))
}
}
/// Read the main database file and its `-wal`, `-shm`, and `-journal`
/// sidecars. Only "not found" is tolerated; any other read error surfaces.
pub(crate) fn database_family_snapshot(db_path: &Path) -> std::io::Result<DatabaseFamilySnapshot> {
DATABASE_FAMILY_SUFFIXES
.into_iter()
.map(|suffix| {
let path = database_family_member_path(db_path, suffix);
let bytes = match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
Ok((suffix.to_string(), bytes))
})
.collect()
}
/// One database-family artifact that changed across an operation that
/// promised to be read-only.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct FamilyByteDiff {
/// `"main database file"`, `"-wal"`, `"-shm"`, or `"-journal"`.
pub artifact: String,
/// `"presence"` (created or removed), `"length"`, or `"bytes"`.
pub kind: &'static str,
/// Human-readable summary of the change.
pub note: String,
/// First differing offsets (at most 16) when `kind == "bytes"`.
pub offsets: Vec<usize>,
/// Bytes before the operation at `offsets`.
pub before: Vec<u8>,
/// Bytes after the operation at `offsets`.
pub after: Vec<u8>,
}
/// Compare two family snapshots under the read-only contract: the main file,
/// WAL, and rollback journal must be byte-identical, and the WAL-index may
/// differ only inside [`SHM_READ_MARK_RANGE`]. Returns one entry per violated
/// artifact; an empty result means the operation was observational.
pub(crate) fn database_family_read_only_diffs(
before: &DatabaseFamilySnapshot,
after: &DatabaseFamilySnapshot,
) -> Vec<FamilyByteDiff> {
let mut diffs = Vec::new();
let suffixes: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
for suffix in suffixes {
let artifact = if suffix.is_empty() {
"main database file".to_string()
} else {
suffix.clone()
};
let before_bytes = before.get(suffix).and_then(Option::as_deref);
let after_bytes = after.get(suffix).and_then(Option::as_deref);
match (before_bytes, after_bytes) {
(None, None) => {}
(Some(_), None) => diffs.push(FamilyByteDiff {
artifact,
kind: "presence",
note: "artifact was removed".to_string(),
offsets: Vec::new(),
before: Vec::new(),
after: Vec::new(),
}),
(None, Some(_)) => diffs.push(FamilyByteDiff {
artifact,
kind: "presence",
note: "artifact was created".to_string(),
offsets: Vec::new(),
before: Vec::new(),
after: Vec::new(),
}),
(Some(before_bytes), Some(after_bytes)) => {
if before_bytes.len() != after_bytes.len() {
diffs.push(FamilyByteDiff {
artifact,
kind: "length",
note: format!(
"length changed from {} to {} bytes",
before_bytes.len(),
after_bytes.len()
),
offsets: Vec::new(),
before: Vec::new(),
after: Vec::new(),
});
continue;
}
let exempt = suffix.as_str() == "-shm";
let offsets: Vec<usize> = before_bytes
.iter()
.zip(after_bytes)
.enumerate()
.filter(|(offset, (before_byte, after_byte))| {
before_byte != after_byte
&& !(exempt && SHM_READ_MARK_RANGE.contains(offset))
})
.map(|(offset, _)| offset)
.take(16)
.collect();
if offsets.is_empty() {
continue;
}
let before = offsets.iter().map(|offset| before_bytes[*offset]).collect();
let after = offsets.iter().map(|offset| after_bytes[*offset]).collect();
diffs.push(FamilyByteDiff {
artifact,
kind: "bytes",
note: format!("bytes changed at {} offset(s)", offsets.len()),
offsets,
before,
after,
});
}
}
}
diffs
}
/// Outcome of [`probe_read_only_open_is_observational`].
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct ReadOnlyOpenProbe {
/// Why the probe did not run, when it did not.
pub skipped: Option<String>,
/// Whether a current-schema read-only open was possible on the copy.
pub opened: bool,
/// Family artifacts that changed in violation of the read-only contract.
pub diffs: Vec<FamilyByteDiff>,
/// Bytes copied into the scratch directory for the probe.
pub copied_bytes: u64,
}
/// Prove, on a private copy of the database family, that
/// [`SqliteStorage::open_current_read_only`] is observational.
///
/// Every file that shares the database's name prefix (the main file, the
/// WAL/SHM/journal sidecars, and fsqlite's namespace and certificate
/// sidecars) is copied into a temporary directory; the four family members
/// are snapshotted, a read-only handle is opened and dropped, and the family
/// is snapshotted again. The caller's own database is never opened by this
/// probe, so it is safe to run inside `br doctor` on a live workspace.
pub(crate) fn probe_read_only_open_is_observational(db_path: &Path) -> Result<ReadOnlyOpenProbe> {
let main_len = std::fs::metadata(db_path)?.len();
if main_len > READ_ONLY_OPEN_PROBE_MAX_BYTES {
return Ok(ReadOnlyOpenProbe {
skipped: Some(format!(
"main database file is {main_len} bytes, above the {READ_ONLY_OPEN_PROBE_MAX_BYTES}-byte probe limit"
)),
opened: false,
diffs: Vec::new(),
copied_bytes: 0,
});
}
let Some(parent) = db_path.parent() else {
return Ok(ReadOnlyOpenProbe {
skipped: Some("database path has no parent directory".to_string()),
opened: false,
diffs: Vec::new(),
copied_bytes: 0,
});
};
let Some(file_name) = db_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
else {
return Ok(ReadOnlyOpenProbe {
skipped: Some("database path has no file name".to_string()),
opened: false,
diffs: Vec::new(),
copied_bytes: 0,
});
};
let scratch = tempfile::tempdir()?;
let copy_path = scratch.path().join(&file_name);
let mut copied_bytes = 0_u64;
for entry in std::fs::read_dir(parent)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with(&file_name) || !entry.file_type()?.is_file() {
continue;
}
copied_bytes += std::fs::copy(entry.path(), scratch.path().join(&name))?;
}
let before = database_family_snapshot(©_path)?;
let opened = match SqliteStorage::open_current_read_only(©_path)? {
Some(storage) => {
drop(storage);
true
}
None => false,
};
let after = database_family_snapshot(©_path)?;
Ok(ReadOnlyOpenProbe {
skipped: None,
opened,
diffs: database_family_read_only_diffs(&before, &after),
copied_bytes,
})
}
fn effective_database_user_version(path: &Path) -> Result<Option<u32>> {
if checked_database_header_user_version(path)?.is_none() {
return Ok(None);
}
let conn = open_with_flags(
path.to_string_lossy().as_ref(),
OpenFlags::SQLITE_OPEN_READ_ONLY,
)?;
let version = connection_user_version(&conn).or(checked_database_header_user_version(path)?);
conn.close().map_err(BeadsError::Database)?;
Ok(version)
}
fn is_transient_wal_tail_read_error(error: &dyn std::fmt::Display) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("wal file is corrupt")
&& (message.contains("short read") || message.contains("short header read"))
}
/// Filter options for listing issues.
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct ListFilters {
pub statuses: Option<Vec<Status>>,
pub types: Option<Vec<IssueType>>,
pub priorities: Option<Vec<Priority>>,
pub assignee: Option<String>,
pub unassigned: bool,
pub include_closed: bool,
pub include_deferred: bool,
pub include_templates: bool,
pub title_contains: Option<String>,
pub limit: Option<usize>,
/// Offset for pagination (number of rows to skip before applying LIMIT)
pub offset: Option<usize>,
/// Sort field (priority, `created_at`, `updated_at`, title)
pub sort: Option<String>,
/// Reverse sort order
pub reverse: bool,
/// Filter by labels (all specified labels must match)
pub labels: Option<Vec<String>>,
/// Filter by labels (OR logic)
pub labels_or: Option<Vec<String>>,
/// Filter by `updated_at` <= timestamp
pub updated_before: Option<DateTime<Utc>>,
/// Filter by `updated_at` >= timestamp
pub updated_after: Option<DateTime<Utc>>,
}
/// Closure-time policy metadata row (issue #274 Phase 1).
///
/// One row per terminal close that carried any opt-in policy data — Tier 1
/// attribution, a `--bypass-policy` waiver, or the list of gates that fired.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloseMetadataRow {
pub closed_by_agent_name: Option<String>,
pub closed_by_harness: Option<String>,
pub closed_by_model: Option<String>,
pub bypassed_policy: bool,
pub bypass_reason: Option<String>,
/// Names of gates that fired during evaluation. Always serialised as a
/// JSON array on disk; empty when no gates fired (e.g. clean Tier 1
/// capture, or a successful close on a project with no `policy.yaml`).
pub policy_gates_fired: Vec<String>,
/// Timestamp the metadata row was recorded, in ISO 8601 / RFC 3339.
pub recorded_at: String,
}
/// Lean issue row used by the stats command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatsIssueRow {
pub id: String,
pub status: Status,
pub priority: Priority,
pub issue_type: IssueType,
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
pub defer_until: Option<DateTime<Utc>>,
pub ephemeral: bool,
pub pinned: bool,
pub is_template: bool,
}
/// Fields to update on an issue.
#[derive(Debug, Clone, Default)]
pub struct IssueUpdate {
pub title: Option<String>,
pub description: Option<Option<String>>,
pub design: Option<Option<String>>,
pub acceptance_criteria: Option<Option<String>>,
pub notes: Option<Option<String>>,
pub status: Option<Status>,
pub priority: Option<Priority>,
pub issue_type: Option<IssueType>,
pub assignee: Option<Option<String>>,
pub owner: Option<Option<String>>,
pub estimated_minutes: Option<Option<i32>>,
pub due_at: Option<Option<DateTime<Utc>>>,
pub defer_until: Option<Option<DateTime<Utc>>>,
pub external_ref: Option<Option<String>>,
/// Override the source-repo display name (typically the repo basename).
/// `Some(Some(s))` sets it to `s`; `Some(None)` resets it to the
/// schema default "." because the column is `NOT NULL`.
pub source_repo: Option<Option<String>>,
/// Override the canonical filesystem path of the repo containing `.beads`.
/// See #289. Use `update --source-repo-path` for ad-hoc repair after a
/// repo is moved/copied to a new machine.
pub source_repo_path: Option<Option<String>>,
/// Set inherited governing-instructions JSON (beads_rust#297).
/// `Some(Some(s))` writes the JSON string `s`; `Some(None)` clears
/// the field back to `NULL`. `None` means "do not touch this field".
/// Validation happens at the CLI boundary; storage is opaque TEXT.
pub agent_context: Option<Option<String>>,
pub closed_at: Option<Option<DateTime<Utc>>>,
pub close_reason: Option<Option<String>>,
pub closed_by_session: Option<Option<String>>,
pub deleted_at: Option<Option<DateTime<Utc>>>,
pub deleted_by: Option<Option<String>>,
pub delete_reason: Option<Option<String>>,
/// New comment bound to this status transition. Storage validates and
/// inserts it in the same transaction as the status change.
pub transition_comment: Option<String>,
/// Audited reason for explicitly bypassing workflow transition gates and
/// required fields. A non-empty value skips those checks for this issue and
/// records a `workflow_policy_bypassed` event in the same transaction.
pub workflow_policy_bypass_reason: Option<String>,
/// If true, do not rebuild the blocked cache after update.
/// Caller is responsible for rebuilding cache if needed.
pub skip_cache_rebuild: bool,
/// If true, verify the issue is unassigned (or assigned to `claim_actor`)
/// inside the IMMEDIATE transaction to prevent TOCTOU races.
pub expect_unassigned: bool,
/// If true, reject re-claims even by the same actor.
pub claim_exclusive: bool,
/// The actor performing the claim (used for idempotent same-actor check).
pub claim_actor: Option<String>,
}
impl IssueUpdate {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.design.is_none()
&& self.acceptance_criteria.is_none()
&& self.notes.is_none()
&& self.status.is_none()
&& self.priority.is_none()
&& self.issue_type.is_none()
&& self.assignee.is_none()
&& self.owner.is_none()
&& self.estimated_minutes.is_none()
&& self.due_at.is_none()
&& self.defer_until.is_none()
&& self.external_ref.is_none()
&& self.source_repo.is_none()
&& self.source_repo_path.is_none()
&& self.agent_context.is_none()
&& self.closed_at.is_none()
&& self.close_reason.is_none()
&& self.closed_by_session.is_none()
&& self.deleted_at.is_none()
&& self.deleted_by.is_none()
&& self.delete_reason.is_none()
&& self.transition_comment.is_none()
&& self.workflow_policy_bypass_reason.is_none()
&& !self.expect_unassigned
}
}
/// Filter options for ready issues.
#[derive(Debug, Clone, Default)]
pub struct ReadyFilters {
pub assignee: Option<String>,
pub unassigned: bool,
pub labels_and: Vec<String>,
pub labels_or: Vec<String>,
pub types: Option<Vec<IssueType>>,
pub priorities: Option<Vec<Priority>>,
pub include_deferred: bool,
/// The status group treated as "ready" (issue #354). Each entry is a
/// canonical-or-custom status string (already lowercased by the caller).
/// Empty means "use the default `[open]` group", which preserves pre-#354
/// behavior exactly. The CLI layer resolves this from
/// `workflow.status_groups.ready` in `.beads/policy.yaml`.
pub ready_statuses: Vec<String>,
pub limit: Option<usize>,
/// Filter to children of this parent issue ID.
pub parent: Option<String>,
/// Include all descendants (grandchildren, etc.) not just direct children.
pub recursive: bool,
/// Pre-resolved parent membership IDs.
///
/// When `parent` is set, the query layer resolves the matching issue IDs in
/// Rust (direct children, or all transitive descendants when `recursive`)
/// and stores them here before building the SQL. The candidate query then
/// filters with a plain `id IN (...)` list instead of a correlated
/// subquery / recursive CTE. This sidesteps engine limitations in the
/// embedded SQLite backend with `IN (subquery)` under multi-table joins
/// (#307) and recursive CTEs referenced from a correlated `EXISTS` (#308),
/// and guarantees bounded traversal via a visited-set BFS even when the
/// parent-child graph contains cycles.
///
/// `Some(empty)` means "parent is set but has no matching descendants" → the
/// candidate query must return no rows. `None` means no parent filter.
pub parent_member_ids: Option<Vec<String>>,
}
/// Minimal metadata needed for fast collision detection during sync.
#[derive(Debug, Clone)]
pub struct IssueMetadata {
pub id: String,
pub external_ref: Option<String>,
pub content_hash: Option<String>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub status: crate::model::Status,
}
/// Sort policy for ready issues.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
pub enum ReadySortPolicy {
/// P0/P1 first by `created_at` ASC, then others by `created_at` ASC
#[default]
Hybrid,
/// Sort by priority ASC, then `created_at` ASC
Priority,
/// Sort by `created_at` ASC only
Oldest,
}
fn sort_ready_hybrid(issues: &mut [Issue]) {
issues.sort_unstable_by(|left, right| {
ready_hybrid_bucket(left.priority)
.cmp(&ready_hybrid_bucket(right.priority))
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
});
}
fn should_sort_list_default_in_rust(filters: &ListFilters) -> bool {
filters.sort.is_none()
&& !filters.reverse
&& filters.offset.is_none_or(|offset| offset == 0)
&& filters.limit.is_none_or(|limit| limit == 0)
}
fn default_visible_limited_page_limit(filters: &ListFilters) -> Option<usize> {
let limit = filters.limit?;
if limit == 0 || filters.offset.is_some_and(|offset| offset > 0) {
return None;
}
let is_default_visible = filters.statuses.as_ref().is_none_or(Vec::is_empty)
&& filters.types.as_ref().is_none_or(Vec::is_empty)
&& filters.priorities.as_ref().is_none_or(Vec::is_empty)
&& filters.assignee.is_none()
&& !filters.unassigned
&& !filters.include_closed
&& !filters.include_templates
&& filters.title_contains.is_none()
&& filters.sort.is_none()
&& !filters.reverse
&& filters.labels.as_ref().is_none_or(Vec::is_empty)
&& filters.labels_or.as_ref().is_none_or(Vec::is_empty)
&& filters.updated_before.is_none()
&& filters.updated_after.is_none();
is_default_visible.then_some(limit)
}
fn default_visible_single_label_count_filter(filters: &ListFilters) -> Option<&str> {
let labels = filters.labels.as_deref()?;
let unique_labels = unique_label_refs(labels);
let [label] = unique_labels.as_slice() else {
return None;
};
let is_default_visible = filters.statuses.as_ref().is_none_or(Vec::is_empty)
&& filters.types.as_ref().is_none_or(Vec::is_empty)
&& filters.priorities.as_ref().is_none_or(Vec::is_empty)
&& filters.assignee.is_none()
&& !filters.unassigned
&& !filters.include_closed
&& !filters.include_templates
&& filters.title_contains.is_none()
&& filters.labels_or.as_ref().is_none_or(Vec::is_empty)
&& filters.updated_before.is_none()
&& filters.updated_after.is_none();
is_default_visible.then_some(label.as_str())
}
fn sort_list_default(issues: &mut [Issue]) {
issues.sort_unstable_by(|left, right| {
left.priority
.cmp(&right.priority)
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.id.cmp(&right.id))
});
}
const fn ready_hybrid_bucket(priority: Priority) -> i32 {
if priority.0 <= 1 { 0 } else { 1 }
}
fn ready_hybrid_high_bucket_priorities(priorities: Option<&[Priority]>) -> Vec<Priority> {
priorities.map_or_else(
|| vec![Priority::CRITICAL, Priority::HIGH],
|values| {
values
.iter()
.copied()
.filter(|priority| ready_hybrid_bucket(*priority) == 0)
.collect()
},
)
}
fn ready_parent_membership_exceeds_sql_parameter_limit(
filters: &ReadyFilters,
sort: ReadySortPolicy,
) -> bool {
let Some(parent_member_ids) = filters.parent_member_ids.as_deref() else {
return false;
};
let configured_priority_count = filters.priorities.as_ref().map_or(0, Vec::len);
let hybrid_priority_count =
if sort == ReadySortPolicy::Hybrid && filters.limit.is_some_and(|limit| limit > 0) {
ready_hybrid_high_bucket_priorities(filters.priorities.as_deref()).len()
} else {
0
};
let non_parent_parameter_count = ready_non_parent_parameter_count(filters)
.saturating_add(hybrid_priority_count.saturating_sub(configured_priority_count));
parent_member_ids.len() > SQLITE_VAR_LIMIT.saturating_sub(non_parent_parameter_count)
}
fn ready_parent_membership_sql_capacity(filters: &ReadyFilters) -> usize {
SQLITE_VAR_LIMIT.saturating_sub(ready_non_parent_parameter_count(filters))
}
fn ready_non_parent_parameter_count(filters: &ReadyFilters) -> usize {
filters
.labels_and
.len()
.saturating_add(filters.labels_or.len())
.saturating_add(filters.types.as_ref().map_or(0, Vec::len))
.saturating_add(filters.priorities.as_ref().map_or(0, Vec::len))
.saturating_add(usize::from(filters.assignee.is_some()))
}
fn sort_ready_issues(issues: &mut [Issue], sort: ReadySortPolicy) {
match sort {
ReadySortPolicy::Hybrid => sort_ready_hybrid(issues),
ReadySortPolicy::Priority => issues.sort_unstable_by(|left, right| {
left.priority
.cmp(&right.priority)
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
}),
ReadySortPolicy::Oldest => issues.sort_unstable_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
}),
}
}
fn parse_status(s: Option<&str>) -> Status {
s.map_or_else(Status::default, |val| {
val.parse()
.unwrap_or_else(|_| Status::Custom(val.to_string()))
})
}
fn parse_issue_type(s: Option<&str>) -> IssueType {
s.and_then(|s| s.parse().ok()).unwrap_or_default()
}
fn dependency_metadata_from_row(
row: &Row,
row_role: &str,
allow_external_placeholder: bool,
) -> Result<IssueWithDependencyMetadata> {
let id = row
.get(0)
.and_then(SqliteValue::as_text)
.ok_or_else(|| BeadsError::Config(format!("{row_role} row missing id")))?;
let dep_type = row
.get(4)
.and_then(SqliteValue::as_text)
.ok_or_else(|| {
BeadsError::Config(format!("{row_role} row missing dependency type for {id}"))
})?
.to_string();
let title = row.get(1).and_then(SqliteValue::as_text);
let status = row.get(2).and_then(SqliteValue::as_text);
let priority = row.get(3).and_then(SqliteValue::as_integer);
let (title, status, priority) = match (title, status, priority) {
(Some(title), Some(status), Some(priority)) => (title, status, priority),
_ if allow_external_placeholder && id.starts_with("external:") => {
return Ok(IssueWithDependencyMetadata {
id: id.to_string(),
title: id.strip_prefix("external:").unwrap_or(id).to_string(),
status: Status::Blocked,
priority: Priority::MEDIUM,
dep_type,
});
}
_ => {
// Graceful fallback for missing dependencies (e.g. deleted/not synced yet)
// instead of crashing the query with a Config error.
return Ok(IssueWithDependencyMetadata {
id: id.to_string(),
title: format!("[missing issue: {}]", id),
status: Status::Tombstone,
priority: Priority::MEDIUM,
dep_type,
});
}
};
let priority = i32::try_from(priority).map_err(|_| {
BeadsError::Config(format!("{row_role} row priority out of range for {id}"))
})?;
Ok(IssueWithDependencyMetadata {
id: id.to_string(),
title: title.to_string(),
status: parse_status(Some(status)),
priority: Priority(priority),
dep_type,
})
}
fn parse_blocked_by_json(issue_id: &str, blockers_json: Option<&str>) -> Result<Vec<String>> {
let blockers_json = blockers_json.ok_or_else(|| {
BeadsError::Config(format!(
"blocked_issues_cache missing blocked_by payload for {issue_id}"
))
})?;
serde_json::from_str(blockers_json).map_err(|err| {
BeadsError::Config(format!("Malformed blocked_by JSON for {issue_id}: {err}"))
})
}
fn parse_external_dependency(dep_id: &str) -> Option<(String, String)> {
let mut parts = dep_id.splitn(3, ':');
let prefix = parts.next()?;
if prefix != "external" {
return None;
}
let project = parts.next()?.to_string();
let capability = parts.next()?.to_string();
if project.is_empty() || capability.is_empty() {
return None;
}
Some((project, capability))
}
fn cycle_endpoint(value: Option<&SqliteValue>) -> String {
value
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string()
}
fn component_is_closed_only(component: &[String], statuses: &BTreeMap<String, Status>) -> bool {
!component.is_empty()
&& component
.iter()
.all(|id| statuses.get(id).is_some_and(Status::is_terminal))
}
fn reverse_cycle_graph(graph: &BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<String>> {
let mut reverse_graph: BTreeMap<String, Vec<String>> = graph
.keys()
.map(|node| (node.clone(), Vec::new()))
.collect();
for (from, neighbors) in graph {
for to in neighbors {
reverse_graph
.entry(to.clone())
.or_default()
.push(from.clone());
}
}
for neighbors in reverse_graph.values_mut() {
neighbors.sort();
neighbors.dedup();
}
reverse_graph
}
fn find_cycle_graph_path(
graph: &BTreeMap<String, Vec<String>>,
start: &str,
target: &str,
component: &HashSet<&str>,
) -> Option<Vec<String>> {
let mut visited = HashSet::new();
let mut stack = vec![(start.to_string(), vec![start.to_string()])];
while let Some((node, path)) = stack.pop() {
if node == target {
return Some(path);
}
if !visited.insert(node.clone()) {
continue;
}
if let Some(neighbors) = graph.get(&node) {
for neighbor in neighbors.iter().rev() {
if component.contains(neighbor.as_str()) && !visited.contains(neighbor) {
let mut next_path = path.clone();
next_path.push(neighbor.clone());
stack.push((neighbor.clone(), next_path));
}
}
}
}
None
}
fn query_external_project_capabilities(
db_path: &Path,
capabilities: &HashSet<String>,
) -> Result<HashSet<String>> {
if capabilities.is_empty() {
return Ok(HashSet::new());
}
let conn = open_existing_read_only_connection(db_path)?;
let labels: Vec<String> = capabilities
.iter()
.map(|cap| format!("provides:{cap}"))
.collect();
let mut satisfied = HashSet::new();
for chunk in labels.chunks(SQLITE_VAR_LIMIT) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let label_sql = format!(
"SELECT label, issue_id
FROM labels
WHERE label IN ({})",
placeholders.join(",")
);
let label_params: Vec<SqliteValue> = chunk
.iter()
.map(|label| SqliteValue::from(label.as_str()))
.collect();
let rows = conn.query_with_params(&label_sql, &label_params)?;
let mut issue_ids_by_capability: HashMap<String, HashSet<String>> = HashMap::new();
for row in &rows {
let Some(label) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(issue_id) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
let Some(capability) = label.strip_prefix("provides:") else {
continue;
};
issue_ids_by_capability
.entry(capability.to_string())
.or_default()
.insert(issue_id.to_string());
}
if issue_ids_by_capability.is_empty() {
continue;
}
let candidate_issue_ids: Vec<String> = issue_ids_by_capability
.values()
.flat_map(|issue_ids| issue_ids.iter().cloned())
.collect();
let mut closed_issue_ids = HashSet::new();
for issue_chunk in candidate_issue_ids.chunks(SQLITE_VAR_LIMIT) {
let issue_placeholders: Vec<&str> = issue_chunk.iter().map(|_| "?").collect();
let issue_sql = format!(
"SELECT id
FROM issues
WHERE status = 'closed' AND id IN ({})",
issue_placeholders.join(",")
);
let issue_params: Vec<SqliteValue> = issue_chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect();
let issue_rows = conn.query_with_params(&issue_sql, &issue_params)?;
for row in &issue_rows {
if let Some(issue_id) = row.get(0).and_then(SqliteValue::as_text) {
closed_issue_ids.insert(issue_id.to_string());
}
}
}
for (capability, issue_ids) in issue_ids_by_capability {
if issue_ids
.iter()
.any(|issue_id| closed_issue_ids.contains(issue_id))
{
satisfied.insert(capability);
}
}
}
// Explicitly close the connection to avoid fsqlite drop_close warnings.
let _ = conn.close();
Ok(satisfied)
}
fn open_existing_read_only_connection(path: &Path) -> Result<Connection> {
if !path.is_file() {
return Err(BeadsError::Config(format!(
"external project database not found: {}",
path.display()
)));
}
open_with_flags(
path.to_string_lossy().as_ref(),
OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(Into::into)
}
fn parse_datetime(s: &str) -> Result<DateTime<Utc>> {
if s.is_empty() {
// NULL/empty datetime columns (common when migrating from bd/Go beads)
// default to epoch rather than crashing the import.
return Ok(DateTime::<Utc>::UNIX_EPOCH);
}
if let Some(dt) = parse_canonical_utc_datetime(s) {
return Ok(dt);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
return Ok(Utc.from_utc_datetime(&naive));
}
Err(BeadsError::Config(format!("unparseable datetime: {s}")))
}
fn parse_canonical_utc_datetime(s: &str) -> Option<DateTime<Utc>> {
let bytes = s.as_bytes();
if bytes.len() < 20
|| bytes.get(4) != Some(&b'-')
|| bytes.get(7) != Some(&b'-')
|| !matches!(bytes.get(10), Some(b'T' | b't' | b' '))
|| bytes.get(13) != Some(&b':')
|| bytes.get(16) != Some(&b':')
{
return None;
}
let year = i32::try_from(parse_fixed_digits(bytes, 0, 4)?).ok()?;
let month = parse_fixed_digits(bytes, 5, 2)?;
let day = parse_fixed_digits(bytes, 8, 2)?;
let hour = parse_fixed_digits(bytes, 11, 2)?;
let minute = parse_fixed_digits(bytes, 14, 2)?;
let second = parse_fixed_digits(bytes, 17, 2)?;
let mut index = 19;
let nanos = if bytes.get(index) == Some(&b'.') {
index += 1;
let mut nanos = 0_u32;
let mut digits = 0_u32;
while let Some(&byte) = bytes.get(index) {
let Some(digit) = decimal_digit(byte) else {
break;
};
if digits == 9 {
return None;
}
nanos = nanos.saturating_mul(10).saturating_add(digit);
digits += 1;
index += 1;
}
if digits == 0 {
return None;
}
for _ in digits..9 {
nanos = nanos.saturating_mul(10);
}
nanos
} else {
0
};
match bytes.get(index..) {
Some(b"Z" | b"z" | b"+00:00") => {}
_ => return None,
}
let date = NaiveDate::from_ymd_opt(year, month, day)?;
let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)?;
Some(Utc.from_utc_datetime(&NaiveDateTime::new(date, time)))
}
fn parse_fixed_digits(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
let mut value = 0_u32;
for &byte in bytes.get(start..start.checked_add(len)?)? {
value = value
.saturating_mul(10)
.saturating_add(decimal_digit(byte)?);
}
Some(value)
}
fn decimal_digit(byte: u8) -> Option<u32> {
byte.is_ascii_digit()
.then_some(u32::from(byte.saturating_sub(b'0')))
}
/// Decode a DATETIME column that may be stored as TEXT (canonical RFC 3339),
/// INTEGER (epoch seconds/ms/µs/ns), REAL (fractional epoch seconds), or NULL.
///
/// SQLite's DATETIME is an advisory type — any storage class is accepted. In
/// practice external tools and older migration paths have written integer
/// epoch microseconds into datetime columns; the legacy reader called
/// `as_text().unwrap_or("")` and then `parse_datetime` silently mapped the
/// empty string to `UNIX_EPOCH`, corrupting the value on export. This helper
/// preserves the data by coercing numeric storage classes into a real
/// `DateTime<Utc>`.
fn parse_datetime_value(value: Option<&SqliteValue>) -> Result<DateTime<Utc>> {
match value {
None | Some(SqliteValue::Null) => Ok(DateTime::<Utc>::UNIX_EPOCH),
Some(SqliteValue::Text(s)) => parse_datetime(s.as_ref()),
Some(SqliteValue::Integer(n)) => datetime_from_epoch_auto(*n),
Some(SqliteValue::Float(f)) => datetime_from_epoch_seconds_f64(*f),
Some(SqliteValue::Blob(_)) => Err(BeadsError::Config(
"unexpected BLOB storage class for datetime column".to_string(),
)),
}
}
/// Like [`parse_datetime_value`] but returns `None` for `NULL` / missing
/// columns instead of `UNIX_EPOCH`. Empty TEXT is treated as `None`.
fn parse_opt_datetime_value(value: Option<&SqliteValue>) -> Result<Option<DateTime<Utc>>> {
match value {
None | Some(SqliteValue::Null) => Ok(None),
Some(SqliteValue::Text(s)) if s.is_empty() => Ok(None),
Some(SqliteValue::Text(s)) => parse_datetime(s.as_ref()).map(Some),
Some(SqliteValue::Integer(n)) => datetime_from_epoch_auto(*n).map(Some),
Some(SqliteValue::Float(f)) => datetime_from_epoch_seconds_f64(*f).map(Some),
Some(SqliteValue::Blob(_)) => Err(BeadsError::Config(
"unexpected BLOB storage class for datetime column".to_string(),
)),
}
}
/// Convert a numeric epoch stored as `i64` into a UTC datetime by auto-
/// detecting the unit from magnitude. We assume any realistic beads timestamp
/// is within a century of the Unix epoch, which gives non-overlapping ranges
/// for seconds (≤10^10), milliseconds (≤10^13), microseconds (≤10^16), and
/// nanoseconds (≤10^19).
fn datetime_from_epoch_auto(n: i64) -> Result<DateTime<Utc>> {
const MS_THRESHOLD: i64 = 10_000_000_000; // ~Nov 2286 as seconds
const US_THRESHOLD: i64 = 10_000_000_000_000;
const NS_THRESHOLD: i64 = 10_000_000_000_000_000;
let abs = n.unsigned_abs();
let (secs, sub_nanos): (i64, u32) = if abs <= MS_THRESHOLD as u64 {
(n, 0)
} else if abs <= US_THRESHOLD as u64 {
let secs = n.div_euclid(1_000);
let rem = epoch_remainder_u32(n, 1_000)?;
(secs, rem * 1_000_000)
} else if abs <= NS_THRESHOLD as u64 {
let secs = n.div_euclid(1_000_000);
let rem = epoch_remainder_u32(n, 1_000_000)?;
(secs, rem * 1_000)
} else {
let secs = n.div_euclid(1_000_000_000);
let rem = epoch_remainder_u32(n, 1_000_000_000)?;
(secs, rem)
};
DateTime::<Utc>::from_timestamp(secs, sub_nanos)
.ok_or_else(|| BeadsError::Config(format!("invalid epoch value in datetime column: {n}")))
}
fn epoch_remainder_u32(n: i64, divisor: i64) -> Result<u32> {
u32::try_from(n.rem_euclid(divisor))
.map_err(|_| BeadsError::Config(format!("invalid epoch value in datetime column: {n}")))
}
fn datetime_from_epoch_seconds_f64(f: f64) -> Result<DateTime<Utc>> {
const MIN_I64_AS_F64: f64 = -9_223_372_036_854_775_808.0;
const MAX_I64_AS_F64: f64 = 9_223_372_036_854_775_807.0;
if !f.is_finite() {
return Err(BeadsError::Config(format!(
"non-finite datetime column value: {f}"
)));
}
// Use floor/fract so the (secs, nanos) split is correct for negative
// timestamps too — with trunc() and abs() on the fraction, `-1.5`
// resolves to `(-1, 500_000_000)` = `-0.5` rather than the intended
// `(-2, 500_000_000)` = `-1.5`. The `fract()` here is f − floor(f),
// which is always in [0, 1) regardless of sign.
let floor_seconds = f.floor();
if !(MIN_I64_AS_F64..=MAX_I64_AS_F64).contains(&floor_seconds) {
return Err(BeadsError::Config(format!(
"invalid epoch value in datetime column: {f}"
)));
}
#[allow(clippy::cast_possible_truncation)]
let mut secs = floor_seconds as i64;
let nanos_f64 = ((f - floor_seconds) * 1_000_000_000.0).round();
let nanos_f64 = if nanos_f64 >= 1_000_000_000.0 {
secs = secs.checked_add(1).ok_or_else(|| {
BeadsError::Config(format!("invalid epoch value in datetime column: {f}"))
})?;
0.0
} else {
nanos_f64.clamp(0.0, 999_999_999.0)
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let nanos = nanos_f64 as u32;
DateTime::<Utc>::from_timestamp(secs, nanos)
.ok_or_else(|| BeadsError::Config(format!("invalid epoch value in datetime column: {f}")))
}
/// Escape special LIKE pattern characters (%, _, \) for literal matching.
///
/// Use with `LIKE ? ESCAPE '\\'` in SQL queries.
fn escape_like_pattern(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
// ============================================================================
// EXPORT/SYNC METHODS
// ============================================================================
impl SqliteStorage {
/// Get issue with all relations populated for export.
///
/// Includes labels, dependencies, and comments.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issue_for_export(&self, id: &str) -> Result<Option<Issue>> {
let Some(mut issue) = self.get_issue(id)? else {
return Ok(None);
};
// Populate relations
issue.labels = self.get_labels(id)?;
issue.dependencies = self.get_dependencies_full(id)?;
issue.comments = self.get_comments(id)?;
Ok(Some(issue))
}
/// Get multiple issues with all relations populated for export.
///
/// Includes labels, dependencies, and comments. This fetches in batch to avoid N+1 queries.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_issues_for_export(&self, ids: &[String]) -> Result<Vec<Issue>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let mut issues = self.get_issues_by_ids(ids)?;
// Fetch relations in batch
let labels_map = self.get_labels_for_issues(ids)?;
let deps_map = self.get_dependencies_full_for_issues(ids)?;
let comments_map = self.get_comments_for_issues(ids)?;
for issue in &mut issues {
if let Some(labels) = labels_map.get(&issue.id) {
issue.labels = labels.clone();
issue.labels.sort();
issue.labels.dedup();
}
if let Some(deps) = deps_map.get(&issue.id) {
issue.dependencies = deps.clone();
}
if let Some(comments) = comments_map.get(&issue.id) {
issue.comments = comments.clone();
}
}
self.attach_close_bypass_audit_for_export(&mut issues)?;
Ok(issues)
}
/// Get dependencies as full Dependency structs for export.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependencies_full(&self, issue_id: &str) -> Result<Vec<crate::model::Dependency>> {
let stmt = self.conn.prepare(
"SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
FROM dependencies
WHERE issue_id = ?
ORDER BY depends_on_id",
)?;
let rows = stmt.query_with_params(&[SqliteValue::from(issue_id)])?;
let mut deps = Vec::with_capacity(rows.len());
for row in &rows {
deps.push(crate::model::Dependency {
issue_id: row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
depends_on_id: row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
dep_type: row
.get(2)
.and_then(SqliteValue::as_text)
.and_then(|s| s.parse().ok())
.unwrap_or(crate::model::DependencyType::Blocks),
created_at: parse_datetime_value(row.get(3))?,
created_by: row
.get(4)
.and_then(SqliteValue::as_text)
.map(str::to_string),
metadata: row
.get(5)
.and_then(SqliteValue::as_text)
.map(str::to_string),
thread_id: row
.get(6)
.and_then(SqliteValue::as_text)
.map(str::to_string),
});
}
Ok(deps)
}
/// Get dependencies as full Dependency structs for multiple issues in batch.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn get_dependencies_full_for_issues(
&self,
issue_ids: &[String],
) -> Result<std::collections::HashMap<String, Vec<crate::model::Dependency>>> {
const SQLITE_VAR_LIMIT: usize = 900;
let mut map: std::collections::HashMap<String, Vec<crate::model::Dependency>> =
std::collections::HashMap::new();
if issue_ids.is_empty() {
return Ok(map);
}
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!(
"SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
FROM dependencies
WHERE issue_id IN ({})
ORDER BY depends_on_id",
placeholders
);
let params: Vec<SqliteValue> = chunk
.iter()
.map(|id| SqliteValue::from(id.as_str()))
.collect();
let rows = self.conn.query_with_params(&sql, ¶ms)?;
for row in &rows {
let dep = crate::model::Dependency {
issue_id: row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
depends_on_id: row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string(),
dep_type: row
.get(2)
.and_then(SqliteValue::as_text)
.and_then(|s| s.parse().ok())
.unwrap_or(crate::model::DependencyType::Blocks),
created_at: parse_datetime_value(row.get(3))?,
created_by: row
.get(4)
.and_then(SqliteValue::as_text)
.map(str::to_string),
metadata: row
.get(5)
.and_then(SqliteValue::as_text)
.map(str::to_string),
thread_id: row
.get(6)
.and_then(SqliteValue::as_text)
.map(str::to_string),
};
map.entry(dep.issue_id.clone()).or_default().push(dep);
}
}
Ok(map)
}
/// Clear dirty flags for the given issue IDs.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn clear_dirty_flags(&mut self, ids: &[String]) -> Result<usize> {
if ids.is_empty() {
return Ok(0);
}
self.with_write_transaction(|storage| storage.clear_dirty_issue_ids_in_tx(ids))
}
/// Clear all dirty flags.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn clear_all_dirty_flags(&mut self) -> Result<usize> {
self.with_write_transaction(Self::clear_all_dirty_issues_in_tx)
}
/// Get the count of issues (for safety guard).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn count_exportable_issues(&self) -> Result<usize> {
let count = self
.conn
.query_row(
"SELECT COUNT(*) FROM issues WHERE ephemeral = 0 AND id NOT LIKE '%-wisp-%'",
)?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
// count is always non-negative from COUNT(*), safe to cast
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Ok(count as usize)
}
/// Check if a dependency exists between two issues.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn dependency_exists_between(&self, issue_id: &str, depends_on_id: &str) -> Result<bool> {
let count = self
.conn
.query_row_with_params(
"SELECT COUNT(*) FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
&[
SqliteValue::from(issue_id),
SqliteValue::from(depends_on_id),
],
)?
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
Ok(count > 0)
}
/// Check if adding a standard dependency edge would create a cycle.
///
/// If `blocking_only` is true, only considers dependency types that affect ready-work
/// blocking: `blocks`, `conditional-blocks`, `waits-for`, and reversed `parent-child`.
/// Use [`Self::would_create_parent_child_cycle`] when validating a stored
/// `parent-child` row, because those rows are reversed in the blocker graph.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn would_create_cycle(
&self,
issue_id: &str,
depends_on_id: &str,
blocking_only: bool,
) -> Result<bool> {
Self::check_cycle(&self.conn, issue_id, depends_on_id, blocking_only)
}
/// Check if adding a `parent-child` row would create a cycle.
///
/// Stored parent rows are `child -> parent`, while dependency graph cycle
/// detection represents them as `parent -> child`.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn would_create_parent_child_cycle(
&self,
child_id: &str,
parent_id: &str,
blocking_only: bool,
) -> Result<bool> {
Self::check_parent_child_cycle(&self.conn, child_id, parent_id, blocking_only)
}
/// Detect all cycles in the dependency graph.
///
/// Since GitHub #391 the graph contains only *blocking* edges (`blocks`,
/// `conditional-blocks`, `waits-for`, plus reversed `parent-child`
/// containment), matching the add-time gate — `related` and other
/// non-blocking types are never cycle-checked on insertion, so they must
/// not fail the report either.
///
/// Returns deterministic cycle witnesses, where each cycle is a vector of
/// issue IDs ending with its starting ID. The implementation finds strongly
/// connected components first, then emits one witness per cyclic component.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn detect_all_cycles(&self) -> Result<Vec<Vec<String>>> {
self.detect_cycles(false)
}
/// Detect cycles in dependency types that affect ready-work blocking.
///
/// This uses the same edge semantics as `would_create_cycle(..., true)`:
/// `blocks`, `conditional-blocks`, and `waits-for` point from dependent to blocker;
/// `parent-child` edges are reversed so parent/child hierarchy cycles are still reported.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn detect_blocking_cycles(&self) -> Result<Vec<Vec<String>>> {
self.detect_cycles(true)
}
fn detect_cycles(&self, _blocking_only: bool) -> Result<Vec<Vec<String>>> {
let graph = self.load_dependency_cycle_graph()?;
Ok(Self::cycle_witnesses_from_graph(&graph))
}
/// Detect dependency cycles split into active and closed-only archive buckets.
///
/// Active cycles include any component with at least one non-terminal issue
/// or a dependency endpoint missing from the local issue table.
///
/// # Errors
///
/// Returns an error if the database query fails.
/// Since GitHub #391, the cycle graph always uses the blocking edge set
/// (matching the add-time gate), so `blocking_only` is a compatible
/// no-op alias retained for the `--blocking-only` CLI flag.
pub fn detect_dependency_cycle_report(
&self,
_blocking_only: bool,
) -> Result<DependencyCycleReport> {
let graph = self.load_dependency_cycle_graph()?;
let statuses = self.load_dependency_cycle_issue_statuses()?;
let witnesses = Self::cycle_witnesses_with_components_from_graph(&graph);
let mut active_cycles = Vec::new();
let mut archived_closed_cycles = Vec::new();
for (component, cycle) in witnesses {
if component_is_closed_only(&component, &statuses) {
archived_closed_cycles.push(cycle);
} else {
active_cycles.push(cycle);
}
}
active_cycles.sort();
archived_closed_cycles.sort();
Ok(DependencyCycleReport {
active_cycles,
archived_closed_cycles,
})
}
fn load_dependency_cycle_graph(&self) -> Result<BTreeMap<String, Vec<String>>> {
Self::load_dependency_cycle_graph_from_conn(&self.conn)
}
fn load_dependency_cycle_graph_from_conn(
conn: &Connection,
) -> Result<BTreeMap<String, Vec<String>>> {
let mut graph: BTreeMap<String, Vec<String>> = BTreeMap::new();
// Cycle health is a *blocking* question, and it must agree with the
// add-time gate: `br dep add -t related` (and custom non-blocking
// types) are never cycle-checked on insertion, so counting those
// edges here made `br dep cycles` fail (nonzero since #368) on
// graphs the add path deliberately allowed (GitHub #391). Both modes
// therefore use the blocking edge set; `--blocking-only` remains a
// compatible alias now that the default matches add-time semantics.
// The reversed parent-child containment edges below participate in
// both modes, exactly like the add-time traversal.
let standard_edge_sql = "SELECT issue_id, depends_on_id FROM dependencies \
WHERE type IN ('blocks', 'conditional-blocks', 'waits-for')";
let rows1 = conn.query(standard_edge_sql)?;
for row in &rows1 {
let from = cycle_endpoint(row.get(0));
let to = cycle_endpoint(row.get(1));
graph.entry(to.clone()).or_default();
graph.entry(from).or_default().push(to);
}
let rows2 = conn.query(
"SELECT depends_on_id, issue_id FROM dependencies WHERE type = 'parent-child'",
)?;
for row in &rows2 {
let from = cycle_endpoint(row.get(0));
let to = cycle_endpoint(row.get(1));
graph.entry(to.clone()).or_default();
graph.entry(from).or_default().push(to);
}
for neighbors in graph.values_mut() {
neighbors.sort();
neighbors.dedup();
}
Ok(graph)
}
fn load_dependency_cycle_issue_statuses(&self) -> Result<BTreeMap<String, Status>> {
let rows = self.conn.query("SELECT id, status FROM issues")?;
let mut statuses = BTreeMap::new();
for row in &rows {
let Some(id) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let status = parse_status(row.get(1).and_then(SqliteValue::as_text));
statuses.insert(id.to_string(), status);
}
Ok(statuses)
}
fn cycle_witnesses_from_graph(graph: &BTreeMap<String, Vec<String>>) -> Vec<Vec<String>> {
Self::cycle_witnesses_with_components_from_graph(graph)
.into_iter()
.map(|(_component, cycle)| cycle)
.collect()
}
fn cycle_witnesses_with_components_from_graph(
graph: &BTreeMap<String, Vec<String>>,
) -> Vec<(Vec<String>, Vec<String>)> {
let components = Self::strongly_connected_components(graph);
let mut cycles = Vec::new();
for component in components {
if component.len() == 1 {
let node = component[0].clone();
if graph
.get(&node)
.is_some_and(|neighbors| neighbors.binary_search(&node).is_ok())
{
cycles.push((component, vec![node.clone(), node]));
}
continue;
}
if let Some(cycle) = Self::cycle_witness_for_component(graph, &component) {
cycles.push((component, cycle));
}
}
cycles.sort_by(|left, right| left.1.cmp(&right.1));
cycles
}
fn strongly_connected_components(graph: &BTreeMap<String, Vec<String>>) -> Vec<Vec<String>> {
let mut visited = HashSet::new();
let mut finish_order = Vec::with_capacity(graph.len());
for node in graph.keys() {
if visited.contains(node) {
continue;
}
Self::push_cycle_graph_finish_order(graph, node, &mut visited, &mut finish_order);
}
let reverse_graph = reverse_cycle_graph(graph);
let mut assigned = HashSet::new();
let mut components = Vec::new();
for node in finish_order.iter().rev() {
if assigned.contains(node) {
continue;
}
let mut component = Vec::new();
let mut stack = vec![node.clone()];
while let Some(current) = stack.pop() {
if !assigned.insert(current.clone()) {
continue;
}
component.push(current.clone());
if let Some(neighbors) = reverse_graph.get(¤t) {
for neighbor in neighbors.iter().rev() {
if !assigned.contains(neighbor) {
stack.push(neighbor.clone());
}
}
}
}
component.sort();
components.push(component);
}
components.sort();
components
}
fn push_cycle_graph_finish_order(
graph: &BTreeMap<String, Vec<String>>,
start: &str,
visited: &mut HashSet<String>,
finish_order: &mut Vec<String>,
) {
let mut stack = vec![(start.to_string(), false)];
while let Some((node, expanded)) = stack.pop() {
if expanded {
finish_order.push(node);
continue;
}
if !visited.insert(node.clone()) {
continue;
}
stack.push((node.clone(), true));
if let Some(neighbors) = graph.get(&node) {
for neighbor in neighbors.iter().rev() {
if !visited.contains(neighbor) {
stack.push((neighbor.clone(), false));
}
}
}
}
}
fn cycle_witness_for_component(
graph: &BTreeMap<String, Vec<String>>,
component: &[String],
) -> Option<Vec<String>> {
let start = component.first()?;
let component_set: HashSet<_> = component.iter().map(String::as_str).collect();
for neighbor in graph.get(start)?.iter().filter(|neighbor| {
neighbor.as_str() != start.as_str() && component_set.contains(neighbor.as_str())
}) {
if let Some(mut path) = find_cycle_graph_path(graph, neighbor, start, &component_set) {
let mut cycle = vec![start.clone()];
cycle.append(&mut path);
return Some(cycle);
}
}
None
}
// ===== Import Helper Methods =====
/// Find an issue by external reference.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn find_by_external_ref(&self, external_ref: &str) -> Result<Option<Issue>> {
match self.conn.query_row_with_params(
r"SELECT 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
FROM issues WHERE external_ref = ?",
&[SqliteValue::from(external_ref)],
) {
Ok(row) => Ok(Some(Self::issue_from_row(&row)?)),
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Find an issue by content hash.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn find_by_content_hash(&self, content_hash: &str) -> Result<Option<Issue>> {
match self.conn.query_row_with_params(
r"SELECT 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
FROM issues WHERE content_hash = ?",
&[SqliteValue::from(content_hash)],
) {
Ok(row) => Ok(Some(Self::issue_from_row(&row)?)),
Err(FrankenError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Check if an issue is a tombstone (deleted).
///
/// # Errors
///
/// Returns an error if the database query fails.
pub fn is_tombstone(&self, id: &str) -> Result<bool> {
Ok(matches!(
Self::get_issue_from_conn(&self.conn, id)?.map(|issue| issue.status),
Some(Status::Tombstone)
))
}
fn import_issue_field_values(
issue: &Issue,
timestamps: &ImportIssueTimestampStrings,
) -> Vec<SqliteValue> {
let status_str = issue.status.as_str();
let issue_type_str = issue.issue_type.as_str();
vec![
issue
.content_hash
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.title.as_str()),
SqliteValue::from(issue.description.as_deref().unwrap_or("")),
SqliteValue::from(issue.design.as_deref().unwrap_or("")),
SqliteValue::from(issue.acceptance_criteria.as_deref().unwrap_or("")),
SqliteValue::from(issue.notes.as_deref().unwrap_or("")),
SqliteValue::from(status_str),
SqliteValue::from(i64::from(issue.priority.0)),
SqliteValue::from(issue_type_str),
issue
.assignee
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.owner.as_deref().unwrap_or("")),
issue
.estimated_minutes
.map_or(SqliteValue::Null, |v| SqliteValue::from(i64::from(v))),
SqliteValue::from(timestamps.created_at.as_str()),
SqliteValue::from(issue.created_by.as_deref().unwrap_or("")),
SqliteValue::from(timestamps.updated_at.as_str()),
timestamps
.closed_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.close_reason.as_deref().unwrap_or("")),
SqliteValue::from(issue.closed_by_session.as_deref().unwrap_or("")),
timestamps
.due_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
timestamps
.defer_until
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
issue
.external_ref
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.source_system.as_deref().unwrap_or("")),
SqliteValue::from(issue.source_repo.as_deref().unwrap_or(".")),
issue
.source_repo_path
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
timestamps
.deleted_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(issue.deleted_by.as_deref().unwrap_or("")),
SqliteValue::from(issue.delete_reason.as_deref().unwrap_or("")),
SqliteValue::from(issue.original_type.as_deref().unwrap_or("")),
SqliteValue::from(i64::from(issue.compaction_level.unwrap_or(0))),
timestamps
.compacted_at
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
issue
.compacted_at_commit
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(i64::from(issue.original_size.unwrap_or(0))),
SqliteValue::from(issue.sender.as_deref().unwrap_or("")),
SqliteValue::from(i64::from(i32::from(issue.ephemeral))),
SqliteValue::from(i64::from(i32::from(issue.pinned))),
SqliteValue::from(i64::from(i32::from(issue.is_template))),
issue
.agent_context
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
]
}
/// Return the exact SQLite values a full import INSERT/UPDATE writes, in
/// physical `issues` table column order. Additive reconciliation uses this
/// to bind implementation-produced raw poststate into its review token.
pub(crate) fn import_issue_raw_row_for_witness(issue: &Issue) -> Result<Vec<SqliteValue>> {
let timestamps = ImportIssueTimestampStrings::from_issue(issue);
let mut fields = Self::import_issue_field_values(issue, ×tamps);
if fields.len() != 37 {
return Err(BeadsError::Config(format!(
"Import issue raw witness expected 37 fields, found {}",
fields.len()
)));
}
// Import SQL places source_repo_path beside source_repo for parameter
// readability, while migrated physical schemas append it immediately
// before agent_context. Reorder into SELECT * / schema-catalog order.
let source_repo_path = fields.remove(23);
let agent_context = fields.pop().ok_or_else(|| {
BeadsError::Config("Import issue raw witness lost the agent_context field".to_string())
})?;
let mut row = Vec::with_capacity(38);
row.push(SqliteValue::from(issue.id.as_str()));
row.extend(fields);
row.push(source_repo_path);
row.push(agent_context);
if row.len() != 38 {
return Err(BeadsError::Config(format!(
"Import issue raw witness expected 38 columns, found {}",
row.len()
)));
}
Ok(row)
}
fn insert_issue_row_for_import(
&self,
issue: &Issue,
timestamps: &ImportIssueTimestampStrings,
) -> Result<usize> {
let mut insert_params = Vec::with_capacity(38);
insert_params.push(SqliteValue::from(issue.id.as_str()));
insert_params.extend(Self::import_issue_field_values(issue, timestamps));
let rows = self.conn.execute_with_params(
r"INSERT INTO issues (
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, source_repo_path,
deleted_at, deleted_by, delete_reason, original_type, compaction_level,
compacted_at, compacted_at_commit, original_size, sender, ephemeral,
pinned, is_template, agent_context
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)",
&insert_params,
)?;
Ok(rows)
}
fn update_issue_row_for_import(
&self,
issue: &Issue,
timestamps: &ImportIssueTimestampStrings,
) -> Result<usize> {
let mut params = Self::import_issue_field_values(issue, timestamps);
params.push(SqliteValue::from(issue.id.as_str()));
let rows = self.conn.execute_with_params(
r"UPDATE issues SET
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 = ?, source_repo_path = ?,
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?, compaction_level = ?,
compacted_at = ?, compacted_at_commit = ?, original_size = ?, sender = ?,
ephemeral = ?, pinned = ?, is_template = ?, agent_context = ?
WHERE id = ?",
¶ms,
)?;
Ok(rows)
}
/// Insert a new issue during JSONL import without first probing for existence.
///
/// This does NOT trigger dirty tracking or events.
///
/// # Errors
///
/// Returns an error if the database operation fails.
#[allow(dead_code)] // Guarded standalone entry point; bulk import uses the in-tx primitive.
pub(crate) fn insert_new_issue_for_import(&self, issue: &Issue) -> Result<bool> {
self.with_connection_write_transaction(|_| self.insert_new_issue_for_import_in_tx(issue))
}
pub(crate) fn insert_new_issue_for_import_in_tx(&self, issue: &Issue) -> Result<bool> {
let timestamps = ImportIssueTimestampStrings::from_issue(issue);
Ok(self.insert_issue_row_for_import(issue, ×tamps)? > 0)
}
/// Upsert an issue (create or update) for import operations.
///
/// For an existing row, runs an in-place UPDATE; for a new row, runs
/// INSERT. The previous implementation used explicit DELETE + INSERT,
/// which cascade-deleted child rows that reference `issues(id)` —
/// events, labels, dependencies, comments — every time an import
/// touched an existing issue (issue #263). The existence check is a
/// narrow `SELECT 1` rather than parsing the row through
/// `get_issue_from_conn`: a malformed-but-present row should be
/// healable by overwriting it with valid JSONL, not blocked by the
/// parser rejecting the bad data first.
///
/// This does NOT trigger dirty tracking or events.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn upsert_issue_for_import(&self, issue: &Issue) -> Result<bool> {
self.with_connection_write_transaction(|_| self.upsert_issue_for_import_in_tx(issue))
}
pub(crate) fn upsert_issue_for_import_in_tx(&self, issue: &Issue) -> Result<bool> {
let timestamps = ImportIssueTimestampStrings::from_issue(issue);
// Narrow existence probe: don't deserialize the row, just check
// if the id is present. If it's malformed we still want to
// overwrite it.
let issue_exists = match self.conn.query_row_with_params(
"SELECT 1 FROM issues WHERE id = ? LIMIT 1",
&[SqliteValue::from(issue.id.as_str())],
) {
Ok(_) => true,
Err(FrankenError::QueryReturnedNoRows) => false,
Err(error) => return Err(error.into()),
};
if issue_exists {
let rows = self.update_issue_row_for_import(issue, ×tamps)?;
if rows == 0 {
return Err(BeadsError::Database(FrankenError::Internal(format!(
"import update did not find existing issue {}",
issue.id
))));
}
self.persist_imported_close_bypass_audit_in_tx(issue)?;
return Ok(true);
}
let inserted = self.insert_issue_row_for_import(issue, ×tamps)? > 0;
if inserted {
self.persist_imported_close_bypass_audit_in_tx(issue)?;
}
Ok(inserted)
}
/// Persist an imported close-policy bypass audit trail into
/// `close_metadata` (GitHub #474). A locally recorded row wins — the
/// machine that performed the bypass holds the richer record (closer
/// attribution columns) — so this is insert-only.
fn persist_imported_close_bypass_audit_in_tx(&self, issue: &Issue) -> Result<()> {
if issue.bypassed_policy != Some(true) {
return Ok(());
}
let gates_json = issue
.policy_gates_fired
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|err| {
BeadsError::Config(format!(
"could not serialize imported policy_gates_fired for {}: {err}",
issue.id
))
})?;
self.conn.execute_with_params(
"INSERT OR IGNORE INTO close_metadata \
(issue_id, bypassed_policy, bypass_reason, policy_gates_fired) \
VALUES (?, 1, ?, ?)",
&[
SqliteValue::from(issue.id.as_str()),
issue
.bypass_reason
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
gates_json.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
Ok(())
}
/// Check whether relation rows already exist for an imported issue ID.
///
/// This is used to guard the insert-only relation fast path. A newly
/// inserted issue row can still attach to stale relation rows that were
/// already present with foreign keys disabled, so callers must verify this
/// before skipping relation deletes.
///
/// # Errors
///
/// Returns an error if the database query fails.
pub(crate) fn has_owned_relation_rows_for_import(&self, issue_id: &str) -> Result<bool> {
let row = self.conn.query_row_with_params(
"SELECT
EXISTS(SELECT 1 FROM labels WHERE issue_id = ?)
OR EXISTS(SELECT 1 FROM dependencies WHERE issue_id = ?)
OR EXISTS(SELECT 1 FROM comments WHERE issue_id = ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(issue_id),
SqliteValue::from(issue_id),
],
)?;
Ok(row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) != 0)
}
/// Verify that a fresh-replacement witness still belongs to this storage's
/// attached database-family authority and current inode.
pub(crate) fn verify_fresh_database_replacement_witness(
&self,
witness: &FreshDatabaseReplacementWitness,
) -> Result<()> {
let authority = self
.write_authority
.as_ref()
.ok_or_else(|| BeadsError::SyncConflict {
message: "Fresh database import has no attached database-family authority"
.to_string(),
})?;
authority.verify_fresh_database_replacement_witness(witness)
}
/// Prove with one query that no owned import-relation rows exist anywhere
/// in the current transaction.
pub(crate) fn import_relation_tables_are_globally_empty_in_tx(&self) -> Result<bool> {
let row = self.conn.query_row(
"SELECT
NOT EXISTS(SELECT 1 FROM labels)
AND NOT EXISTS(SELECT 1 FROM dependencies)
AND NOT EXISTS(SELECT 1 FROM comments)",
)?;
Ok(row.get(0).and_then(SqliteValue::as_integer).unwrap_or(0) != 0)
}
/// Replace an issue's dirty marker inside the current write transaction.
///
/// # Errors
///
/// Returns an error if the marker cannot be updated.
#[allow(dead_code)] // Guarded standalone entry point; bulk import uses the in-tx primitive.
pub(crate) fn replace_dirty_issue_marker(&self, issue_id: &str, marked_at: &str) -> Result<()> {
self.with_connection_write_transaction(|_| {
self.replace_dirty_issue_marker_in_tx(issue_id, marked_at)
})
}
pub(crate) fn replace_dirty_issue_marker_in_tx(
&self,
issue_id: &str,
marked_at: &str,
) -> Result<()> {
self.conn.execute_with_params(
"DELETE FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
self.conn.execute_with_params(
"INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?)",
&[SqliteValue::from(issue_id), SqliteValue::from(marked_at)],
)?;
Ok(())
}
/// Sync labels for an issue (remove existing, add new).
///
/// # Errors
///
/// Returns an error if the label replacement is invalid or the database
/// operation fails.
pub fn sync_labels_for_import(&self, issue_id: &str, labels: &[String]) -> Result<()> {
self.with_connection_write_transaction(|_| {
self.sync_labels_for_import_in_tx(issue_id, labels)
})
}
pub(crate) fn sync_labels_for_import_in_tx(
&self,
issue_id: &str,
labels: &[String],
) -> Result<()> {
let unique_labels = unique_label_refs(labels);
validate_storage_label_refs(&unique_labels)?;
// Remove existing labels
self.conn.execute_with_params(
"DELETE FROM labels WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
self.insert_label_refs_for_import(issue_id, &unique_labels)
}
fn insert_labels_for_import(&self, issue_id: &str, labels: &[String]) -> Result<()> {
let unique_labels = unique_label_refs(labels);
validate_storage_label_refs(&unique_labels)?;
self.insert_label_refs_for_import(issue_id, &unique_labels)
}
fn insert_label_refs_for_import(
&self,
issue_id: &str,
unique_labels: &[&String],
) -> Result<()> {
if unique_labels.is_empty() {
return Ok(());
}
// Keep label inserts single-row: fsqlite can mis-handle multi-values
// inserts with repeated issue_id bindings on this primary key.
for label in unique_labels {
self.conn.execute_with_params(
"INSERT OR IGNORE INTO labels (issue_id, label) VALUES (?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(label.as_str()),
],
)?;
}
Ok(())
}
/// Sync dependencies for an issue (remove existing, add new).
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn sync_dependencies_for_import(
&self,
issue_id: &str,
dependencies: &[crate::model::Dependency],
) -> Result<()> {
self.with_connection_write_transaction(|_| {
self.sync_dependencies_for_import_in_tx(issue_id, dependencies)
})
}
pub(crate) fn sync_dependencies_for_import_in_tx(
&self,
issue_id: &str,
dependencies: &[crate::model::Dependency],
) -> Result<()> {
let unique_deps = Self::validated_unique_import_dependencies(issue_id, dependencies)?;
// Remove existing dependencies where this issue is the dependent
self.conn.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
self.insert_dependency_refs_for_import(issue_id, &unique_deps)
}
fn insert_dependencies_for_import(
&self,
issue_id: &str,
dependencies: &[crate::model::Dependency],
) -> Result<()> {
let unique_deps = Self::validated_unique_import_dependencies(issue_id, dependencies)?;
self.insert_dependency_refs_for_import(issue_id, &unique_deps)
}
fn validated_unique_import_dependencies<'a>(
issue_id: &str,
dependencies: &'a [Dependency],
) -> Result<Vec<&'a Dependency>> {
let mut seen_deps = HashSet::new();
let mut unique_deps = Vec::new();
for (dep_index, dep) in dependencies.iter().enumerate() {
if dep.issue_id != issue_id {
return Err(BeadsError::validation(
"dependency.issue_id",
format!(
"dependency issue_id '{}' does not match import issue '{}'",
dep.issue_id, issue_id
),
));
}
if issue_id == dep.depends_on_id {
return Err(BeadsError::SelfDependency {
id: issue_id.to_string(),
});
}
if let Some(metadata) = dep.metadata.as_deref() {
serde_json::from_str::<serde_json::Value>(metadata).map_err(|err| {
BeadsError::Validation {
field: format!("dependencies[{dep_index}].metadata"),
reason: format!(
"dependency metadata must be valid JSON for issue {issue_id} -> \
{target} (type={dep_type}); found {value}: {err}{hint}",
target = dep.depends_on_id,
dep_type = dep.dep_type.as_str(),
value = if metadata.is_empty() {
"empty string".to_string()
} else {
format!("{metadata:?}")
},
hint = if metadata.trim().is_empty() {
" (suggested fix: replace \"\" with \"{}\")"
} else {
""
},
),
}
})?;
}
Self::validate_parent_child_endpoints(
issue_id,
&dep.depends_on_id,
dep.dep_type.as_str(),
)?;
// Deduplicate by target because the dependencies table is keyed by
// (issue_id, depends_on_id). Type-distinct duplicates would be
// ignored by insertion anyway.
if seen_deps.insert(dep.depends_on_id.as_str()) {
unique_deps.push(dep);
}
}
Ok(unique_deps)
}
fn insert_dependency_refs_for_import(
&self,
issue_id: &str,
unique_deps: &[&Dependency],
) -> Result<()> {
if unique_deps.is_empty() {
return Ok(());
}
for chunk in unique_deps.chunks(IMPORT_DEPENDENCY_CHUNK_SIZE) {
let placeholders: Vec<String> = chunk
.iter()
.map(|_| "(?, ?, ?, ?, ?, ?, ?)".to_string())
.collect();
let sql = format!(
"INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) VALUES {}",
placeholders.join(", ")
);
let mut params = Vec::with_capacity(chunk.len() * 7);
for dep in chunk {
params.push(SqliteValue::from(issue_id));
params.push(SqliteValue::from(dep.depends_on_id.as_str()));
params.push(SqliteValue::from(dep.dep_type.as_str()));
params.push(SqliteValue::from(dep.created_at.to_rfc3339().as_str()));
params.push(SqliteValue::from(
dep.created_by.as_deref().unwrap_or("import"),
));
params.push(SqliteValue::from(dep.metadata.as_deref().unwrap_or("{}")));
params.push(SqliteValue::from(dep.thread_id.as_deref().unwrap_or("")));
}
self.conn.execute_with_params(&sql, ¶ms)?;
}
Ok(())
}
/// Sync comments for an issue (remove existing, add new).
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub fn sync_comments_for_import(
&self,
issue_id: &str,
comments: &[crate::model::Comment],
) -> Result<()> {
self.with_connection_write_transaction(|_| {
self.sync_comments_for_import_in_tx(issue_id, comments)
})
}
pub(crate) fn sync_comments_for_import_in_tx(
&self,
issue_id: &str,
comments: &[crate::model::Comment],
) -> Result<()> {
validate_import_comments_for_issue(issue_id, comments)?;
// Remove existing comments
self.conn.execute_with_params(
"DELETE FROM comments WHERE issue_id = ?",
&[SqliteValue::from(issue_id)],
)?;
self.insert_comment_rows_for_import(issue_id, comments)
}
/// Delete comments owned by issues that an outer import transaction will
/// replace.
///
/// Comment ids are database-local rowids that JSONL merely carries along;
/// a merged JSONL can move an id between issues or (two clones each adding
/// a comment, GitHub #486) publish the same id for two issues. Clearing
/// the complete applied-owner set before any issue is replayed lets ids
/// that do line up land unchanged regardless of JSONL line order, while
/// `insert_comment_for_import` reassigns any id that still collides.
/// Callers must invoke this inside the same transaction that restores the
/// replacement rows.
///
/// # Errors
///
/// Returns an error if the database operation fails.
pub(crate) fn delete_comments_for_import_issue_ids_in_tx(
&self,
issue_ids: &[String],
) -> Result<usize> {
let mut deleted = 0usize;
for chunk in issue_ids.chunks(SQLITE_VAR_LIMIT) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!("DELETE FROM comments WHERE issue_id IN ({placeholders})");
let params = chunk
.iter()
.map(|issue_id| SqliteValue::from(issue_id.as_str()))
.collect::<Vec<_>>();
deleted += self.conn.execute_with_params(&sql, ¶ms)?;
}
Ok(deleted)
}
/// Insert relation rows for an issue that was just created during import.
///
/// The caller must only use this after a successful new issue insert. Update
/// and upsert paths must keep using the `sync_*_for_import` methods so stale
/// relation rows are removed.
///
/// # Errors
///
/// Returns an error if any relation insert fails.
#[allow(dead_code)] // Guarded standalone entry point; bulk import uses the in-tx primitive.
pub(crate) fn insert_new_issue_relations_for_import(&self, issue: &Issue) -> Result<()> {
self.with_connection_write_transaction(|_| {
self.insert_new_issue_relations_for_import_in_tx(issue)
})
}
pub(crate) fn insert_new_issue_relations_for_import_in_tx(&self, issue: &Issue) -> Result<()> {
self.insert_labels_for_import(&issue.id, &issue.labels)?;
self.insert_dependencies_for_import(&issue.id, &issue.dependencies)?;
self.insert_comments_for_import(&issue.id, &issue.comments)?;
Ok(())
}
/// Upsert one imported issue and replace all of its owned relations in one
/// authority-verified transaction.
#[allow(dead_code)] // Guarded standalone entry point; bulk import/merge use one outer transaction.
pub(crate) fn upsert_issue_and_relations_for_import(&self, issue: &Issue) -> Result<bool> {
self.with_connection_write_transaction(|_| {
let changed = self.upsert_issue_for_import_in_tx(issue)?;
self.sync_labels_for_import_in_tx(&issue.id, &issue.labels)?;
self.sync_dependencies_for_import_in_tx(&issue.id, &issue.dependencies)?;
self.sync_comments_for_import_in_tx(&issue.id, &issue.comments)?;
Ok(changed)
})
}
/// Apply the complete database side of one reviewed three-way merge in a
/// single write transaction.
///
/// Deletions, issue rows, owned relations, resolution notes, dirty
/// markers, export-hash invalidation, operational caches, child counters,
/// and the merge-pending receipt either all commit or all roll back.
///
/// # Errors
///
/// Returns an error if validation or any database operation fails.
#[allow(clippy::too_many_lines)]
pub(crate) fn apply_sync_merge_atomically(
&mut self,
kept: &[Issue],
deleted_ids: &[String],
notes: &[(String, String)],
intent: &SyncMergeIntent,
) -> Result<SyncMergePendingReceipt> {
self.last_capacity_warnings.clear();
let actor = intent.actor.as_str();
if actor.trim().is_empty() || actor.trim() != actor {
return Err(BeadsError::validation(
"actor",
"sync merge actor must be nonblank and trimmed",
));
}
let timestamp = intent.export_as_of;
let created_at = timestamp.to_rfc3339();
for (issue_id, note) in notes {
validate_new_comment(issue_id, "br-sync", note)?;
}
let kept_by_id = kept
.iter()
.map(|issue| (issue.id.as_str(), issue))
.collect::<BTreeMap<_, _>>();
if kept_by_id.len() != kept.len() {
return Err(BeadsError::validation(
"kept",
"sync merge report contains duplicate kept issue IDs",
));
}
let deleted_set = deleted_ids
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
if deleted_set.len() != deleted_ids.len() {
return Err(BeadsError::validation(
"deleted",
"sync merge report contains duplicate deleted issue IDs",
));
}
if let Some(overlap) = kept_by_id
.keys()
.find(|issue_id| deleted_set.contains(**issue_id))
{
return Err(BeadsError::validation(
"merge_report",
format!("issue {overlap} is both kept and deleted"),
));
}
let note_ids = notes
.iter()
.map(|(issue_id, _)| issue_id.as_str())
.collect::<HashSet<_>>();
if note_ids.len() != notes.len() {
return Err(BeadsError::validation(
"notes",
"sync merge report contains duplicate note targets",
));
}
for note_id in ¬e_ids {
let issue = kept_by_id.get(note_id).ok_or_else(|| {
BeadsError::validation(
"notes",
format!("merge note target {note_id} is not a kept issue"),
)
})?;
if issue.status == Status::Tombstone {
return Err(BeadsError::validation(
"notes",
format!("merge note target {note_id} is a tombstone"),
));
}
}
let mut actual_kept_ids = kept_by_id
.keys()
.map(|id| (*id).to_string())
.collect::<Vec<_>>();
actual_kept_ids.sort();
let mut actual_deleted_ids = deleted_ids.to_vec();
actual_deleted_ids.sort();
let mut actual_note_witnesses = notes
.iter()
.map(|(issue_id, note)| crate::sync::SyncMergeNoteWitness {
issue_id: issue_id.clone(),
note_sha256: crate::util::hex_encode(&Sha256::digest(note.as_bytes())),
})
.collect::<Vec<_>>();
actual_note_witnesses.sort_by(|left, right| left.issue_id.cmp(&right.issue_id));
let actual_kept_issue_witnesses = crate::sync::sync_merge_kept_issue_witnesses(kept)?;
if actual_kept_ids != intent.changed_kept_issue_ids
|| actual_kept_issue_witnesses != intent.kept_issue_witnesses
|| actual_deleted_ids != intent.deleted_issue_ids
|| actual_note_witnesses != intent.note_witnesses
{
return Err(BeadsError::SyncConflict {
message: "Sync merge mutation payload does not match its reviewed intent"
.to_string(),
});
}
if intent.schema_version != 2 {
return Err(BeadsError::SyncConflict {
message: format!(
"Unsupported sync merge intent schema {}",
intent.schema_version
),
});
}
let pending_attribution = self.pending_event_attribution_for_review();
if pending_attribution != intent.event_attribution {
return Err(BeadsError::SyncConflict {
message: "Sync merge event attribution changed after its intent was reviewed"
.to_string(),
});
}
let reviewed_attribution = intent.event_attribution.clone();
if self.workflow_capacity_policy != intent.capacity_policy {
return Err(BeadsError::SyncConflict {
message:
"Workflow capacity policy changed after the sync merge intent was reviewed"
.to_string(),
});
}
let capacity_policy = intent.capacity_policy.clone();
let result = self.with_write_transaction(|storage| {
match storage.inspect_pending_sync_merge_in_current_transaction()? {
PendingSyncMergeInspection::Absent => {}
pending => {
return Err(BeadsError::SyncConflict {
message: format!(
"{}; refusing to begin a second sync merge",
pending.diagnostic()
),
});
}
}
let database_before = crate::sync::capture_sync_database_witness(storage)?;
if database_before != intent.database_before {
return Err(BeadsError::SyncConflict {
message:
"Database changed after sync merge planning; refusing to clobber the newer generation"
.to_string(),
});
}
let mut changed_ids = kept
.iter()
.map(|issue| issue.id.clone())
.collect::<HashSet<_>>();
changed_ids.extend(notes.iter().map(|(issue_id, _)| issue_id.clone()));
// Capacity is a final-state property of the complete merge, not a
// sequence of independent row writes. Build one transition batch
// from the transaction's exact prestate so a kept/new issue cannot
// bypass limits and a capacity-neutral swap is not rejected merely
// because its admitting row happens to be applied first.
let mut affected_ids = kept
.iter()
.map(|issue| issue.id.clone())
.chain(deleted_ids.iter().cloned())
.collect::<Vec<_>>();
affected_ids.sort();
affected_ids.dedup();
let existing_by_id = storage
.get_issues_by_ids(&affected_ids)?
.into_iter()
.map(|issue| (issue.id.clone(), issue))
.collect::<HashMap<_, _>>();
let mut capacity_transitions = Vec::with_capacity(affected_ids.len());
for issue in kept {
let from = existing_by_id
.get(&issue.id)
.map(|existing| existing.status.as_str().to_string());
if from
.as_deref()
.is_none_or(|status| !status.eq_ignore_ascii_case(issue.status.as_str()))
{
capacity_transitions.push(CapacityBatchTransition {
issue_id: issue.id.clone(),
from,
to: issue.status.as_str().to_string(),
issue_type: Some(issue.issue_type.as_str().to_string()),
current_assignee: existing_by_id
.get(&issue.id)
.and_then(|existing| existing.assignee.clone()),
prospective_assignee: issue.assignee.clone(),
});
}
}
let tombstones = deleted_ids
.iter()
.filter_map(|issue_id| existing_by_id.get(issue_id).cloned())
.collect::<Vec<_>>();
for tombstone in &tombstones {
if tombstone.status != Status::Tombstone {
capacity_transitions.push(CapacityBatchTransition {
issue_id: tombstone.id.clone(),
from: Some(tombstone.status.as_str().to_string()),
to: Status::Tombstone.as_str().to_string(),
issue_type: None,
current_assignee: tombstone.assignee.clone(),
prospective_assignee: tombstone.assignee.clone(),
});
}
}
capacity_transitions.sort_by(|left, right| left.issue_id.cmp(&right.issue_id));
let acting = CapacityActingContext::new(actor, &reviewed_attribution);
let capacity_warnings = Self::evaluate_workflow_capacity_batch_in_tx(
&storage.conn,
&capacity_policy,
&capacity_transitions,
&acting,
)?;
for tombstone in &tombstones {
if tombstone.status == Status::Tombstone {
continue;
}
let was_terminal = tombstone.status.is_terminal();
let original_type = tombstone.issue_type.as_str().to_string();
let mut tombstone_for_hash = tombstone.clone();
tombstone_for_hash.status = Status::Tombstone;
let tombstone_hash = crate::util::content_hash(&tombstone_for_hash);
storage.conn.execute_with_params(
"UPDATE issues SET
content_hash = ?,
status = 'tombstone',
deleted_at = ?,
deleted_by = ?,
delete_reason = ?,
original_type = ?,
updated_at = ?
WHERE id = ?",
&[
SqliteValue::from(tombstone_hash.as_str()),
SqliteValue::from(created_at.as_str()),
SqliteValue::from(actor),
SqliteValue::from("merge deletion"),
SqliteValue::from(original_type.as_str()),
SqliteValue::from(created_at.as_str()),
SqliteValue::from(tombstone.id.as_str()),
],
)?;
storage.conn.execute_with_params(
"DELETE FROM close_metadata WHERE issue_id = ?",
&[SqliteValue::from(tombstone.id.as_str())],
)?;
if !was_terminal {
storage.insert_sync_merge_event_in_tx(
&tombstone.id,
&EventType::Deleted,
actor,
Some("Deleted issue: merge deletion"),
&created_at,
&reviewed_attribution,
)?;
}
changed_ids.insert(tombstone.id.clone());
}
// Materialize every issue row before validating/inserting
// dependency relations so references between two newly merged
// rows do not depend on report ordering.
for issue in kept {
storage.upsert_issue_for_import_in_tx(issue)?;
}
for issue in kept {
storage.sync_labels_for_import_in_tx(&issue.id, &issue.labels)?;
storage.sync_dependencies_for_import_in_tx(&issue.id, &issue.dependencies)?;
storage.sync_comments_for_import_in_tx(&issue.id, &issue.comments)?;
}
for (issue_id, note) in notes {
Self::ensure_issue_mutable_in_tx(&storage.conn, issue_id, "add merge note to")?;
storage.conn.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) \
VALUES (?, ?, ?, ?)",
&[
SqliteValue::from(issue_id.as_str()),
SqliteValue::from("br-sync"),
SqliteValue::from(note.as_str()),
SqliteValue::from(created_at.as_str()),
],
)?;
storage.conn.execute_with_params(
"UPDATE issues SET updated_at = ? WHERE id = ?",
&[
SqliteValue::from(created_at.as_str()),
SqliteValue::from(issue_id.as_str()),
],
)?;
storage.insert_sync_merge_event_in_tx(
issue_id,
&EventType::Commented,
actor,
Some(note),
&created_at,
&reviewed_attribution,
)?;
}
let mut changed_ids = changed_ids.into_iter().collect::<Vec<_>>();
changed_ids.sort();
for issue_id in &changed_ids {
storage.replace_dirty_issue_marker_in_tx(issue_id, &created_at)?;
}
storage.clear_export_hashes_in_tx(&changed_ids)?;
storage.set_metadata_in_tx("needs_flush", "true")?;
storage.rebuild_blocked_cache_in_tx()?;
storage.rebuild_child_counters_in_tx()?;
let database_after = crate::sync::capture_sync_merge_core_witness(storage)?;
let mut sink = std::io::sink();
let (export_result, _) =
crate::sync::export_to_writer_with_policy_and_retention_at(
storage,
&mut sink,
crate::sync::ExportErrorPolicy::Strict,
intent.retention_days,
intent.export_as_of,
)?;
let receipt = SyncMergePendingReceipt::new(
intent.clone(),
timestamp.to_rfc3339(),
database_after,
export_result.content_hash,
export_result.exported_count,
&export_result.issue_hashes,
capacity_warnings.clone(),
)?;
receipt.validate()?;
let receipt_serialized = serde_json::to_string(&receipt)?;
storage.set_metadata_in_tx(
METADATA_SYNC_MERGE_PENDING,
&receipt_serialized,
)?;
storage.require_exact_pending_sync_merge_row_in_current_transaction(
&receipt_serialized,
"New sync merge receipt was not durably materialized before COMMIT",
)?;
Ok((receipt, capacity_warnings))
});
if result.is_ok() {
self.pending_event_attribution = None;
}
let (receipt, capacity_warnings) = result?;
self.last_capacity_warnings = capacity_warnings;
Ok(receipt)
}
fn insert_sync_merge_event_in_tx(
&self,
issue_id: &str,
event_type: &EventType,
actor: &str,
comment: Option<&str>,
created_at: &str,
attribution: &EventAttribution,
) -> Result<()> {
self.conn.execute_with_params(
"INSERT INTO events (
issue_id, event_type, actor, old_value, new_value, comment,
created_at, agent_name, harness, model
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(event_type.as_str()),
SqliteValue::from(actor),
SqliteValue::Null,
SqliteValue::Null,
comment.map_or(SqliteValue::Null, SqliteValue::from),
SqliteValue::from(created_at),
attribution
.agent_name
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
attribution
.harness
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
attribution
.model
.as_deref()
.map_or(SqliteValue::Null, SqliteValue::from),
],
)?;
Ok(())
}
fn pending_sync_merge_metadata_rows(&self, key: &str) -> Result<Vec<Option<String>>> {
let rows = self.conn.query_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid",
&[SqliteValue::from(key)],
)?;
Ok(rows
.iter()
.map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect())
}
fn inspect_pending_sync_merge_in_current_transaction(
&self,
) -> Result<PendingSyncMergeInspection> {
let current_rows = self.pending_sync_merge_metadata_rows(METADATA_SYNC_MERGE_PENDING)?;
let legacy_rows =
self.pending_sync_merge_metadata_rows(METADATA_SYNC_MERGE_PENDING_LEGACY)?;
Ok(classify_pending_sync_merge_rows(
¤t_rows,
&legacy_rows,
))
}
fn require_exact_pending_sync_merge_row_in_current_transaction(
&self,
expected_serialized: &str,
operation: &str,
) -> Result<()> {
let current_rows = self.pending_sync_merge_metadata_rows(METADATA_SYNC_MERGE_PENDING)?;
let legacy_rows =
self.pending_sync_merge_metadata_rows(METADATA_SYNC_MERGE_PENDING_LEGACY)?;
let exact_current = matches!(
current_rows.as_slice(),
[Some(serialized)] if serialized == expected_serialized
);
if exact_current && legacy_rows.is_empty() {
return Ok(());
}
let diagnostic = classify_pending_sync_merge_rows(¤t_rows, &legacy_rows).diagnostic();
Err(BeadsError::SyncConflict {
message: format!(
"{operation}: the exact pending sync-merge receipt row changed or became ambiguous ({diagnostic})"
),
})
}
/// Inspect both pending-sync-merge metadata keys in one coherent read
/// transaction.
///
/// Query/open uncertainty is returned as an error. `Absent` is produced
/// only after both exact raw row sets were read successfully from the same
/// SQLite snapshot.
///
/// Callers must invoke this outside any caller-managed SQLite transaction.
/// If an in-transaction caller is added, it must use the private
/// `inspect_pending_sync_merge_in_current_transaction` classifier directly.
pub(crate) fn inspect_pending_sync_merge(&self) -> Result<PendingSyncMergeInspection> {
self.with_read_transaction(Self::inspect_pending_sync_merge_in_current_transaction)
}
/// Inspect pending-sync-merge state on an existing current-schema database
/// while a caller-owned database-family authority prevents replacement.
///
/// A definitively missing file or a file that cannot possibly be SQLite
/// cannot contain a pending receipt and is classified `Absent`. Valid
/// SQLite files with stale/future schemas, route mismatches, open errors,
/// query errors, and receipt validation failures all fail closed. The
/// read-only storage carries the caller's authority, so
/// `with_read_transaction` verifies it before the transaction, immediately
/// before COMMIT, and again after COMMIT.
///
/// Read-only contract (GitHub #476): the main database file, WAL, and
/// rollback journal are never written. The WAL-index (`-shm`) is the one
/// exception, and only its reader-mark array: the read-only connection
/// takes a WAL read lock so an uncheckpointed WAL is observed (#373), and
/// the WAL reader protocol registers that snapshot in `aReadMark` exactly
/// as stock SQLite does. Nothing durable changes, with one content-free
/// exception: group/other permission bits on fsqlite's namespace lock
/// sidecars are stripped to owner-only under the held authority first,
/// because the engine refuses every open — read-only included — until they
/// are (GitHub #403, #491).
pub(crate) fn inspect_pending_sync_merge_under_authority(
path: &Path,
authority: &Arc<crate::sync::DatabaseFamilyWriteLock>,
) -> Result<PendingSyncMergeInspection> {
let planned_authority = crate::sync::database_write_authority_sha256(path)?;
if planned_authority != authority.authority_path_sha256() {
return Err(BeadsError::SyncConflict {
message:
"Pending sync-merge inspection path does not match the held database-family authority"
.to_string(),
});
}
if authority.bind_database_inode_for_mutation()? {
authority.verify_database_authority()?;
return Ok(PendingSyncMergeInspection::Absent);
}
authority.verify_database_authority()?;
let mut header = [0_u8; 16];
let is_sqlite = std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut header))
.is_ok()
&& &header == b"SQLite format 3\0";
authority.verify_database_authority()?;
if !is_sqlite {
return Ok(PendingSyncMergeInspection::Absent);
}
// Pending-saga classification precedes every database-family content
// mutation. The one thing allowed before it is the authority-gated
// owner-only mode repair of fsqlite's namespace sidecars: the engine
// refuses even a read-only open while a sidecar is group/other
// accessible, so without the repair no verdict is reachable and every
// command — including the `br sync --merge` that would resume a
// pending saga — stays wedged (GitHub #403 regression, #491). The
// repair changes no byte of the database, WAL, journal, or sidecar
// records, only permission bits on regenerable lock files, and it is
// fail-closed on authority, inode identity, and schema readability
// (see `heal_namespace_sidecar_modes_under_authority`). A filesystem
// that cannot hold the bits fails here with the named limitation.
heal_namespace_sidecar_modes_under_authority(path, authority)?;
authority.verify_database_authority()?;
let Some(mut storage) = Self::open_current_read_only(path)? else {
let found = effective_database_user_version(path)?;
return match found {
Some(found) => Err(BeadsError::SchemaMismatch {
expected: CURRENT_SCHEMA_VERSION,
found: i32::try_from(found).unwrap_or(i32::MAX),
}),
None => Err(BeadsError::SyncConflict {
message:
"Pending sync-merge state is unknown because the database schema is missing or unreadable"
.to_string(),
}),
};
};
authority.verify_database_authority()?;
storage.attach_write_authority(Arc::clone(authority));
storage.inspect_pending_sync_merge()
}
pub(crate) fn pending_sync_merge_receipt(&self) -> Result<Option<SyncMergePendingReceipt>> {
match self.inspect_pending_sync_merge()? {
PendingSyncMergeInspection::Absent => Ok(None),
PendingSyncMergeInspection::Valid(receipt) => Ok(Some(*receipt)),
pending @ (PendingSyncMergeInspection::Legacy { .. }
| PendingSyncMergeInspection::Malformed { .. }) => Err(BeadsError::SyncConflict {
message: format!(
"{}; refusing automatic recovery until `br sync --merge` reconciles it",
pending.diagnostic()
),
}),
}
}
pub(crate) fn compare_and_set_pending_sync_merge_receipt(
&mut self,
expected: &SyncMergePendingReceipt,
replacement: &SyncMergePendingReceipt,
) -> Result<()> {
expected.validate()?;
replacement.validate()?;
if expected.phase != crate::sync::SyncMergePendingPhase::DatabaseCommitted
|| replacement.phase != crate::sync::SyncMergePendingPhase::ExportFinalized
{
return Err(BeadsError::SyncConflict {
message:
"Pending sync merge phase update must advance database_committed to export_finalized"
.to_string(),
});
}
if expected.receipt_id != replacement.receipt_id
|| expected.intent_sha256 != replacement.intent_sha256
{
return Err(BeadsError::SyncConflict {
message: "Pending sync merge phase update changed immutable receipt identity"
.to_string(),
});
}
let Some(jsonl_after) = replacement.jsonl_after.as_ref() else {
return Err(BeadsError::SyncConflict {
message:
"Export-finalized sync merge receipt must witness a published JSONL source"
.to_string(),
});
};
let Some(export_finalization) = replacement.export_finalization.as_ref() else {
return Err(BeadsError::SyncConflict {
message:
"Export-finalized sync merge receipt must witness database export bookkeeping"
.to_string(),
});
};
let exact_advancement = expected
.advance_to_export_finalized(jsonl_after.clone(), export_finalization.clone())?;
if replacement != &exact_advancement {
return Err(BeadsError::SyncConflict {
message: "Pending sync merge phase update changed immutable receipt evidence"
.to_string(),
});
}
let expected_serialized = serde_json::to_string(expected)?;
let replacement_serialized = serde_json::to_string(replacement)?;
self.with_write_transaction(|storage| {
storage.require_exact_pending_sync_merge_row_in_current_transaction(
&expected_serialized,
"Pending sync merge receipt changed before phase advancement",
)?;
if crate::sync::capture_sync_merge_core_witness(storage)? != expected.database_after {
return Err(BeadsError::SyncConflict {
message:
"Database merge-authoritative state changed before pending merge phase advancement"
.to_string(),
});
}
let live_finalization =
crate::sync::capture_sync_merge_export_finalization_witness(storage)?;
if replacement.export_finalization.as_ref() != Some(&live_finalization) {
return Err(BeadsError::SyncConflict {
message:
"Database export bookkeeping changed before pending merge phase advancement"
.to_string(),
});
}
storage.set_metadata_in_tx(METADATA_SYNC_MERGE_PENDING, &replacement_serialized)
})
}
pub(crate) fn compare_and_clear_pending_sync_merge_receipt(
&mut self,
expected: &SyncMergePendingReceipt,
) -> Result<()> {
expected.validate()?;
let (
crate::sync::SyncMergePendingPhase::ExportFinalized,
Some(crate::sync::JsonlSourceStateWitness::Present {
raw_sha256: terminal_raw_sha256,
..
}),
) = (expected.phase, expected.jsonl_after.as_ref())
else {
return Err(BeadsError::SyncConflict {
message:
"Pending sync merge receipt may be cleared only after exact export finalization"
.to_string(),
});
};
if terminal_raw_sha256 != &expected.jsonl_after_raw_sha256 {
return Err(BeadsError::SyncConflict {
message: "Terminal sync merge source witness does not match reviewed export bytes"
.to_string(),
});
}
let expected_serialized = serde_json::to_string(expected)?;
self.with_write_transaction(|storage| {
storage.require_exact_pending_sync_merge_row_in_current_transaction(
&expected_serialized,
"Pending sync merge receipt changed before terminal cleanup",
)?;
if crate::sync::capture_sync_merge_core_witness(storage)? != expected.database_after {
return Err(BeadsError::SyncConflict {
message:
"Database merge-authoritative state changed before pending merge terminal cleanup"
.to_string(),
});
}
let live_finalization =
crate::sync::capture_sync_merge_export_finalization_witness(storage)?;
if expected.export_finalization.as_ref() != Some(&live_finalization) {
return Err(BeadsError::SyncConflict {
message:
"Database export bookkeeping changed before pending merge terminal cleanup"
.to_string(),
});
}
storage.conn.execute_with_params(
"DELETE FROM metadata WHERE key = ? AND value = ?",
&[
SqliteValue::from(METADATA_SYNC_MERGE_PENDING),
SqliteValue::from(expected_serialized.as_str()),
],
)?;
Ok(())
})
}
fn insert_comments_for_import(
&self,
issue_id: &str,
comments: &[crate::model::Comment],
) -> Result<()> {
validate_import_comments_for_issue(issue_id, comments)?;
self.insert_comment_rows_for_import(issue_id, comments)
}
fn insert_comment_rows_for_import(
&self,
issue_id: &str,
comments: &[crate::model::Comment],
) -> Result<()> {
if comments.is_empty() {
return Ok(());
}
for comment in comments {
self.insert_comment_for_import(issue_id, comment)?;
}
Ok(())
}
fn insert_comment_for_import(&self, issue_id: &str, comment: &Comment) -> Result<()> {
let created_at = comment.created_at.to_rfc3339();
if comment.id <= 0 {
return self.insert_import_comment_without_id(issue_id, comment, &created_at);
}
match self.insert_import_comment_with_id(issue_id, comment, &created_at) {
Ok(()) => Ok(()),
Err(BeadsError::Database(error)) if is_import_comment_id_collision(&error) => {
match self.import_comment_id_owner(comment.id)? {
// Whenever ANY row already owns this id — a comment on
// another issue OR an earlier comment of *this* issue whose
// id was AUTO-reallocated to the same value during this
// import — reinsert without an explicit id so AUTOINCREMENT
// assigns a fresh one. Comment identity for sync is the
// payload (`Comment::sync_key`), never the rowid, so the
// semantic verifier accepts the reassigned id (GitHub
// #486). True same-issue JSONL duplicates are rejected
// earlier by `validate_import_comments_for_issue`, so this
// cannot silently swallow a genuine duplicate (issue #374).
Some(_) => {
self.insert_import_comment_without_id(issue_id, comment, &created_at)
}
None => Err(BeadsError::Database(error)),
}
}
Err(error) => Err(error),
}
}
fn insert_import_comment_without_id(
&self,
issue_id: &str,
comment: &Comment,
created_at: &str,
) -> Result<()> {
self.conn.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(comment.author.as_str()),
SqliteValue::from(comment.body.as_str()),
SqliteValue::from(created_at),
],
)?;
Ok(())
}
fn insert_import_comment_with_id(
&self,
issue_id: &str,
comment: &Comment,
created_at: &str,
) -> Result<()> {
self.conn.execute_with_params(
"INSERT INTO comments (id, issue_id, author, text, created_at) VALUES (?, ?, ?, ?, ?)",
&[
SqliteValue::from(comment.id),
SqliteValue::from(issue_id),
SqliteValue::from(comment.author.as_str()),
SqliteValue::from(comment.body.as_str()),
SqliteValue::from(created_at),
],
)?;
Ok(())
}
fn import_comment_id_owner(&self, comment_id: i64) -> Result<Option<String>> {
Ok(self
.conn
.query_with_params(
"SELECT issue_id FROM comments WHERE id = ? LIMIT 1",
&[SqliteValue::from(comment_id)],
)?
.into_iter()
.next()
.and_then(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
}))
}
}
fn is_import_comment_id_collision(error: &FrankenError) -> bool {
matches!(
error,
FrankenError::PrimaryKeyViolation | FrankenError::UniqueViolation { .. }
) || matches!(
error,
FrankenError::Internal(message)
if message.contains("VDBE halted with code 19")
&& (message.contains("PRIMARY KEY constraint failed")
|| message.contains("UNIQUE constraint failed"))
)
}
/// Implement the `DependencyStore` trait for `SqliteStorage`.
impl crate::validation::DependencyStore for SqliteStorage {
fn issue_exists(&self, id: &str) -> std::result::Result<bool, crate::error::BeadsError> {
self.id_exists(id)
}
fn dependency_exists(
&self,
issue_id: &str,
depends_on_id: &str,
) -> std::result::Result<bool, crate::error::BeadsError> {
self.dependency_exists_between(issue_id, depends_on_id)
}
fn would_create_cycle(
&self,
issue_id: &str,
depends_on_id: &str,
) -> std::result::Result<bool, crate::error::BeadsError> {
Self::check_cycle(&self.conn, issue_id, depends_on_id, true)
}
fn would_create_parent_child_cycle(
&self,
child_id: &str,
parent_id: &str,
) -> std::result::Result<bool, crate::error::BeadsError> {
Self::check_parent_child_cycle(&self.conn, child_id, parent_id, true)
}
}
fn validate_new_comment(issue_id: &str, author: &str, text: &str) -> Result<()> {
let comment = Comment {
id: 1,
issue_id: issue_id.to_string(),
author: author.to_string(),
body: text.to_string(),
created_at: Utc::now(),
};
CommentValidator::validate(&comment).map_err(BeadsError::from_validation_errors)
}
fn validate_issue_comments_for_create(issue: &Issue) -> Result<()> {
for comment in &issue.comments {
validate_new_comment(&issue.id, &comment.author, &comment.body)?;
}
Ok(())
}
fn validate_import_comments_for_issue(issue_id: &str, comments: &[Comment]) -> Result<()> {
let mut seen_comment_ids = HashSet::new();
for comment in comments {
if comment.issue_id != issue_id {
return Err(BeadsError::validation(
"comment.issue_id",
format!(
"comment issue_id '{}' does not match import issue '{}'",
comment.issue_id, issue_id
),
));
}
let comment_for_validation = Comment {
id: 1,
issue_id: issue_id.to_string(),
author: comment.author.clone(),
body: comment.body.clone(),
created_at: comment.created_at,
};
CommentValidator::validate(&comment_for_validation)
.map_err(BeadsError::from_validation_errors)?;
if comment.id > 0 && !seen_comment_ids.insert(comment.id) {
return Err(BeadsError::validation(
"comment.id",
format!("duplicate import comment id {}", comment.id),
));
}
}
Ok(())
}
fn insert_comment_row(conn: &Connection, issue_id: &str, author: &str, text: &str) -> Result<i64> {
conn.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(issue_id),
SqliteValue::from(author),
SqliteValue::from(text),
],
)?;
let row = conn.query_row("SELECT last_insert_rowid()")?;
let comment_id = row
.get(0)
.and_then(SqliteValue::as_integer)
.ok_or_else(|| {
BeadsError::Config("comments insert did not return last_insert_rowid".to_string())
})?;
if comment_id <= 0 {
return Err(BeadsError::Config(format!(
"comments insert returned invalid last_insert_rowid: {comment_id}"
)));
}
Ok(comment_id)
}
fn gate_result_record_from_row(row: &Row) -> Result<crate::close_policy::GateResultRecord> {
let required_text = |index: usize, name: &str| {
row.get(index)
.and_then(SqliteValue::as_text)
.map(String::from)
.ok_or_else(|| BeadsError::Config(format!("gate-result history row missing {name}")))
};
Ok(crate::close_policy::GateResultRecord {
id: row
.get(0)
.and_then(SqliteValue::as_integer)
.ok_or_else(|| BeadsError::Config("gate-result history row missing id".to_string()))?,
issue_id: required_text(1, "issue_id")?,
from_status: required_text(2, "from_status")?,
to_status: required_text(3, "to_status")?,
status_revision: row
.get(4)
.and_then(SqliteValue::as_integer)
.ok_or_else(|| {
BeadsError::Config("gate-result history row missing status_revision".to_string())
})?,
gate: required_text(5, "gate")?,
provider: required_text(6, "provider")?,
passed: row
.get(7)
.and_then(SqliteValue::as_integer)
.unwrap_or_default()
!= 0,
note: row.get(8).and_then(SqliteValue::as_text).map(String::from),
recorded_by: row.get(9).and_then(SqliteValue::as_text).map(String::from),
recorded_at: required_text(10, "recorded_at")?,
})
}
fn fetch_comment(conn: &Connection, comment_id: i64) -> Result<Comment> {
let row = match conn.query_row_with_params(
"SELECT id, issue_id, author, text, created_at FROM comments WHERE id = ?",
&[SqliteValue::from(comment_id)],
) {
Ok(row) => row,
Err(FrankenError::QueryReturnedNoRows) => {
return Err(BeadsError::Config(format!(
"comment {comment_id} not found after insert"
)));
}
Err(error) => return Err(error.into()),
};
comment_from_row(&row)
}
fn comment_from_row(row: &Row) -> Result<Comment> {
let id = row
.get(0)
.and_then(SqliteValue::as_integer)
.ok_or_else(|| BeadsError::Config("comments row missing id".to_string()))?;
let issue_id = row
.get(1)
.and_then(SqliteValue::as_text)
.ok_or_else(|| BeadsError::Config(format!("comments row missing issue_id for {id}")))?
.to_string();
let author = row
.get(2)
.and_then(SqliteValue::as_text)
.ok_or_else(|| BeadsError::Config(format!("comments row missing author for {id}")))?
.to_string();
let body = row
.get(3)
.and_then(SqliteValue::as_text)
.ok_or_else(|| BeadsError::Config(format!("comments row missing body for {id}")))?
.to_string();
let created_at_value = row
.get(4)
.ok_or_else(|| BeadsError::Config(format!("comments row missing created_at for {id}")))?;
let created_at = parse_datetime_value(Some(created_at_value)).map_err(|err| match err {
BeadsError::Config(msg) => {
BeadsError::Config(format!("invalid comment timestamp for {id}: {msg}"))
}
other => other,
})?;
Ok(Comment {
id,
issue_id,
author,
body,
created_at,
})
}
fn dedupe_preserving_order(values: &[String]) -> Vec<String> {
let mut seen = HashSet::<&str>::new();
let mut deduped = Vec::with_capacity(values.len());
for value in values {
if seen.insert(value) {
deduped.push(value.clone());
}
}
deduped
}
fn validate_storage_label(label: &str) -> Result<()> {
LabelValidator::validate(label).map_err(|error| BeadsError::validation("label", error.message))
}
fn validate_storage_labels(labels: &[String]) -> Result<()> {
if labels.len() > ISSUE_LABEL_MAX_COUNT {
return Err(label_count_error());
}
for label in labels {
validate_storage_label(label)?;
}
Ok(())
}
fn validate_storage_label_refs(labels: &[&String]) -> Result<()> {
if labels.len() > ISSUE_LABEL_MAX_COUNT {
return Err(label_count_error());
}
for label in labels {
validate_storage_label(label.as_str())?;
}
Ok(())
}
fn label_count_error() -> BeadsError {
BeadsError::validation("labels", format!("exceeds {ISSUE_LABEL_MAX_COUNT} labels"))
}
impl Drop for SqliteStorage {
fn drop(&mut self) {
// Read-only commands leave `mutation_count` at zero, so they keep
// the original "no checkpoint on teardown" behaviour that prevents
// spurious busy failures under parallel read traffic. Mutating
// commands that committed since the last periodic checkpoint —
// including the abnormal-exit case where signal-induced shutdown
// (`crate::shutdown`) returns from main without re-entering
// `with_write_transaction` — get one final TRUNCATE here so WAL
// frames are not stranded on disk after the process ends (#270).
//
// The checkpoint runs only while this process provably is the sole
// opener; the exclusive opener hold is kept until the connection is
// closed so no peer starts reading a WAL this teardown is resetting.
let mut exit_hold = None;
if self.mutation_count > 0 {
match self.admit_checkpoint() {
CheckpointAdmission::Sole(hold) => {
exit_hold = hold;
if let Err(e) = self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") {
tracing::debug!(error = %e, "WAL checkpoint on drop failed (non-fatal)");
}
}
CheckpointAdmission::PeersPresent => {
tracing::debug!(
"Skipping exit WAL checkpoint: another process has the database open"
);
}
}
}
// Explicitly close the connection to avoid fsqlite drop_close warnings.
let _ = self.conn.close_in_place();
drop(exit_hold);
// Ephemeral temp databases (open_memory) are unlinked here, after the
// connection is closed, so the file and its WAL/SHM/journal sidecars are
// not left behind in TMPDIR (#299). Persistent databases have
// `temp_db_path == None` and are never touched.
if let Some(path) = self.temp_db_path.take() {
remove_temp_db_files(&path);
}
}
}
#[cfg(test)]
impl SqliteStorage {
/// Execute raw SQL for tests.
///
/// # Errors
///
/// Returns an error if the SQL execution fails.
pub fn execute_test_sql(&self, sql: &str) -> Result<()> {
crate::storage::schema::execute_batch(&self.conn, sql)?;
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::similar_names)]
mod tests {
use super::*;
use crate::format::{
BlockedIssueOutput, ReadyIssue, StaleIssue, TextFormatOptions, format_issue_line_with,
};
use crate::model::{Issue, IssueType, Priority, Status};
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
use std::fs;
use tempfile::TempDir;
fn make_issue(
id: &str,
title: &str,
status: Status,
priority: i32,
assignee: Option<&str>,
created_at: DateTime<Utc>,
defer_until: Option<DateTime<Utc>>,
) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
status,
priority: Priority(priority),
issue_type: IssueType::Task,
created_at,
updated_at: created_at,
defer_until,
content_hash: None,
description: None,
design: None,
acceptance_criteria: None,
notes: None,
assignee: assignee.map(str::to_string),
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
fn sync_merge_test_intent(
storage: &SqliteStorage,
kept: &[Issue],
deleted_ids: &[String],
notes: &[(String, String)],
) -> SyncMergeIntent {
let mut changed_kept_issue_ids = kept
.iter()
.map(|issue| issue.id.clone())
.collect::<Vec<_>>();
changed_kept_issue_ids.sort();
let kept_issue_witnesses = crate::sync::sync_merge_kept_issue_witnesses(kept).unwrap();
let mut deleted_issue_ids = deleted_ids.to_vec();
deleted_issue_ids.sort();
let mut note_witnesses = notes
.iter()
.map(|(issue_id, note)| crate::sync::SyncMergeNoteWitness {
issue_id: issue_id.clone(),
note_sha256: crate::util::hex_encode(&Sha256::digest(note.as_bytes())),
})
.collect::<Vec<_>>();
note_witnesses.sort_by(|left, right| left.issue_id.cmp(&right.issue_id));
SyncMergeIntent {
schema_version: 2,
database_authority_sha256: "11".repeat(32),
jsonl_authority_sha256: "22".repeat(32),
jsonl_path_sha256: "33".repeat(32),
jsonl_before: crate::sync::JsonlSourceStateWitness::Missing,
jsonl_before_content_sha256: None,
base_authority_sha256: "44".repeat(32),
base_before: crate::sync::JsonlSourceStateWitness::Missing,
base_before_content_sha256: None,
resolution: "manual".to_string(),
actor: "merge-agent".to_string(),
event_attribution: storage.pending_event_attribution_for_review(),
capacity_policy: storage.workflow_capacity_policy_for_review(),
retention_days: None,
export_as_of: Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap(),
changed_kept_issue_ids,
kept_issue_witnesses,
deleted_issue_ids,
note_witnesses,
database_before: crate::sync::capture_sync_database_witness(storage).unwrap(),
}
}
fn sync_merge_test_export_hashes(
storage: &SqliteStorage,
intent: &SyncMergeIntent,
) -> Vec<(String, String)> {
let mut sink = Vec::new();
crate::sync::export_to_writer_with_policy_and_retention_at(
storage,
&mut sink,
crate::sync::ExportErrorPolicy::Strict,
intent.retention_days,
intent.export_as_of,
)
.unwrap()
.0
.issue_hashes
}
fn finalized_sync_merge_test_receipt(
storage: &mut SqliteStorage,
receipt: &SyncMergePendingReceipt,
) -> SyncMergePendingReceipt {
let dirty_ids = storage.get_dirty_issue_ids().unwrap();
storage.clear_dirty_flags(&dirty_ids).unwrap();
let reviewed_issue_hashes = sync_merge_test_export_hashes(storage, &receipt.intent);
storage.clear_all_export_hashes().unwrap();
storage.set_export_hashes(&reviewed_issue_hashes).unwrap();
storage
.set_metadata(
METADATA_JSONL_CONTENT_HASH,
&receipt.jsonl_after_content_sha256,
)
.unwrap();
storage
.set_metadata(METADATA_JSONL_MTIME, "2026-07-27T08:00:00+00:00")
.unwrap();
storage.set_metadata(METADATA_JSONL_SIZE, "128").unwrap();
storage
.set_metadata(METADATA_LAST_EXPORT_TIME, &receipt.created_at)
.unwrap();
storage.set_metadata("needs_flush", "false").unwrap();
receipt
.advance_to_export_finalized(
crate::sync::JsonlSourceStateWitness::Present {
raw_sha256: receipt.jsonl_after_raw_sha256.clone(),
mtime: "2026-07-27T08:00:00+00:00".to_string(),
size: 128,
identity: None,
},
crate::sync::capture_sync_merge_export_finalization_witness(storage).unwrap(),
)
.unwrap()
}
fn assert_sync_merge_payload_rejected_without_writes(
storage: &mut SqliteStorage,
kept: &[Issue],
deleted_ids: &[String],
notes: &[(String, String)],
) -> BeadsError {
let intent = sync_merge_test_intent(storage, kept, deleted_ids, notes);
let before = crate::sync::capture_sync_database_witness(storage).unwrap();
let error = storage
.apply_sync_merge_atomically(kept, deleted_ids, notes, &intent)
.unwrap_err();
assert_eq!(
crate::sync::capture_sync_database_witness(storage).unwrap(),
before
);
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
error
}
fn assert_sync_merge_substituted_issue_rejected_without_writes(
storage: &mut SqliteStorage,
planned: &Issue,
substituted: &Issue,
) {
let intent = sync_merge_test_intent(storage, std::slice::from_ref(planned), &[], &[]);
let before = crate::sync::capture_sync_database_witness(storage).unwrap();
let error = storage
.apply_sync_merge_atomically(std::slice::from_ref(substituted), &[], &[], &intent)
.unwrap_err();
assert!(
matches!(error, BeadsError::SyncConflict { .. }),
"same-ID payload substitution must fail as a reviewed-intent conflict: {error}"
);
assert_eq!(
crate::sync::capture_sync_database_witness(storage).unwrap(),
before,
"rejected same-ID payload substitution must perform zero writes"
);
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
}
/// GitHub #403 / #491: a pending receipt behind namespace sidecars with
/// broader access than its private database must still be classified. The
/// authority-gated mode repair is the only thing allowed before the verdict
/// — it changes no byte of any family member — and the verdict reports the receipt.
#[cfg(unix)]
#[test]
fn pending_inspection_heals_sidecar_modes_then_classifies_valid_receipt_without_byte_changes() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("pending_receipt_permissive_sidecar.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let issue = make_issue(
"bd-pending-sidecar",
"Pending sidecar inspection",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.expect("write a valid pending receipt");
assert!(matches!(
storage.inspect_pending_sync_merge().unwrap(),
PendingSyncMergeInspection::Valid(_)
));
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
// The repair precondition must not depend on the process umask: 0.3.18
// admits sidecars whose exposure is no broader than the database's.
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let sidecars = existing_namespace_sidecars(&db_path);
assert!(!sidecars.is_empty(), "namespace sidecar fixture");
for sidecar in &sidecars {
fs::set_permissions(sidecar, fs::Permissions::from_mode(0o664)).unwrap();
}
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let family_before = directory_bytes_and_modes(temp.path());
let inspection =
SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("pending inspection reaches a verdict after the sidecar mode repair");
assert!(
matches!(inspection, PendingSyncMergeInspection::Valid(_)),
"expected the valid pending receipt, got {inspection:?}"
);
let family_after = directory_bytes_and_modes(temp.path());
let sidecar_names: Vec<String> = sidecars
.iter()
.map(|sidecar| sidecar.file_name().unwrap().to_string_lossy().into_owned())
.collect();
for (name, (bytes_before, mode_before)) in &family_before {
let (bytes_after, mode_after) = family_after
.get(name)
.unwrap_or_else(|| panic!("{name} vanished during pending inspection"));
// The read-only verdict connection registers its WAL snapshot in
// the `-shm` reader-mark array exactly as stock SQLite does
// (#476); every other family member must be byte-identical.
if !name.ends_with("-shm") {
assert_eq!(bytes_after, bytes_before, "{name} bytes changed");
}
if sidecar_names.contains(name) {
assert_eq!(mode_before & 0o077, 0o064, "{name} fixture mode");
assert_eq!(
mode_after & 0o077,
0,
"{name} must be owner-only after the repair (mode {:04o})",
mode_after & 0o7777
);
} else {
assert_eq!(mode_after, mode_before, "{name} mode changed");
}
}
}
#[test]
fn pending_sync_merge_raw_classifier_fails_closed_on_legacy_null_and_duplicates() {
assert!(matches!(
classify_pending_sync_merge_rows(&[], &[]),
PendingSyncMergeInspection::Absent
));
assert!(matches!(
classify_pending_sync_merge_rows(&[], &[Some("legacy-receipt".to_string())]),
PendingSyncMergeInspection::Legacy { row_count: 1, .. }
));
for current in [
vec![None],
vec![Some(String::new())],
vec![Some("{}".to_string()), Some("{}".to_string())],
] {
assert!(
matches!(
classify_pending_sync_merge_rows(¤t, &[]),
PendingSyncMergeInspection::Malformed { .. }
),
"current rows must fail closed: {current:?}"
);
}
for legacy in [
vec![None],
vec![Some(" ".to_string())],
vec![Some("legacy-a".to_string()), Some("legacy-b".to_string())],
] {
assert!(
matches!(
classify_pending_sync_merge_rows(&[], &legacy),
PendingSyncMergeInspection::Malformed { .. }
),
"legacy rows must fail closed: {legacy:?}"
);
}
assert!(matches!(
classify_pending_sync_merge_rows(
&[Some("{}".to_string())],
&[Some("legacy-receipt".to_string())],
),
PendingSyncMergeInspection::Malformed { .. }
));
}
#[test]
fn pending_sync_merge_raw_classifier_requires_exact_canonical_receipt_bytes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let issue = make_issue(
"bd-canonical-receipt",
"Canonical receipt",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let receipt = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
let canonical = serde_json::to_string(&receipt).unwrap();
assert!(matches!(
classify_pending_sync_merge_rows(&[Some(canonical)], &[]),
PendingSyncMergeInspection::Valid(observed) if *observed == receipt
));
let pretty = serde_json::to_string_pretty(&receipt).unwrap();
assert!(matches!(
classify_pending_sync_merge_rows(&[Some(pretty)], &[]),
PendingSyncMergeInspection::Malformed { .. }
));
let mut with_unknown = serde_json::to_value(&receipt).unwrap();
with_unknown
.as_object_mut()
.unwrap()
.insert("unrecognized_state".to_string(), serde_json::json!(true));
assert!(matches!(
classify_pending_sync_merge_rows(
&[Some(serde_json::to_string(&with_unknown).unwrap())],
&[],
),
PendingSyncMergeInspection::Malformed { .. }
));
}
#[test]
fn sync_merge_transaction_refuses_legacy_malformed_and_duplicate_pending_rows() {
for (case, rows) in [
(
"empty-current",
vec![(METADATA_SYNC_MERGE_PENDING, String::new())],
),
(
"legacy",
vec![(
METADATA_SYNC_MERGE_PENDING_LEGACY,
"legacy-pending-state".to_string(),
)],
),
(
"duplicate-current",
vec![
(METADATA_SYNC_MERGE_PENDING, "{}".to_string()),
(METADATA_SYNC_MERGE_PENDING, "{}".to_string()),
],
),
] {
let mut storage = SqliteStorage::open_memory().unwrap();
for (key, value) in rows {
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[SqliteValue::from(key), SqliteValue::from(value.as_str())],
)
.unwrap();
}
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let issue = make_issue(
&format!("bd-refuse-{case}"),
"Must not commit",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
assert!(matches!(
storage.apply_sync_merge_atomically(&kept, &[], &[], &intent),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before,
"{case} must reject before any merge write"
);
assert!(
!matches!(
storage.inspect_pending_sync_merge().unwrap(),
PendingSyncMergeInspection::Absent
),
"{case} must preserve the exact blocking state"
);
}
}
fn hard_status_capacity(status: &str, hard: u32) -> crate::close_policy::CapacityPolicy {
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.statuses.insert(
status.to_string(),
crate::close_policy::CapacityLimit {
soft: None,
hard: Some(hard),
},
);
policy
}
fn required_review_fields_workflow() -> crate::close_policy::Workflow {
// Presence of required_fields enables these checks independently of
// strict status-vocabulary enforcement.
let mut workflow = crate::close_policy::Workflow::default();
workflow.required_fields.insert(
"in_progress -> in_review".to_string(),
vec![
crate::close_policy::TransitionRequiredField::AcceptanceCriteria,
crate::close_policy::TransitionRequiredField::TransitionComment,
],
);
workflow
}
#[test]
fn transition_required_fields_and_comment_commit_atomically() {
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_policy(required_review_fields_workflow());
let issue = make_issue(
"bd-review",
"review candidate",
Status::InProgress,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_comment("bd-review", "tester", "an old historical comment")
.unwrap();
let missing = IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
..Default::default()
};
let error = storage
.update_issue("bd-review", &missing, "tester")
.unwrap_err();
assert!(matches!(error, BeadsError::PolicyViolation { .. }));
let unchanged = storage.get_issue("bd-review").unwrap().unwrap();
assert_eq!(unchanged.status, Status::InProgress);
assert!(unchanged.acceptance_criteria.is_none());
assert_eq!(storage.get_comments("bd-review").unwrap().len(), 1);
let unchecked = IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
acceptance_criteria: Some(Some("- [ ] Exercise the real path".to_string())),
transition_comment: Some("fresh review attempt".to_string()),
..Default::default()
};
storage
.update_issue("bd-review", &unchecked, "tester")
.unwrap_err();
let unchanged = storage.get_issue("bd-review").unwrap().unwrap();
assert_eq!(unchanged.status, Status::InProgress);
assert!(unchanged.acceptance_criteria.is_none());
assert_eq!(storage.get_comments("bd-review").unwrap().len(), 1);
let valid = IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
acceptance_criteria: Some(Some("- [x] Exercise the real path".to_string())),
transition_comment: Some("fresh review attempt".to_string()),
..Default::default()
};
let transitioned = storage.update_issue("bd-review", &valid, "tester").unwrap();
assert_eq!(transitioned.status.as_str(), "in_review");
assert_eq!(
transitioned.acceptance_criteria.as_deref(),
Some("- [x] Exercise the real path")
);
let comments = storage.get_comments("bd-review").unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[1].body, "fresh review attempt");
}
#[test]
fn transition_required_fields_preflight_entire_batch_before_mutation() {
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_policy(required_review_fields_workflow());
for id in ["bd-batch-a", "bd-batch-b"] {
let issue = make_issue(id, id, Status::InProgress, 2, None, Utc::now(), None);
storage.create_issue(&issue, "tester").unwrap();
}
let updates = vec![
(
"bd-batch-a".to_string(),
IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
acceptance_criteria: Some(Some("- [x] Complete".to_string())),
transition_comment: Some("A is ready".to_string()),
..Default::default()
},
),
(
"bd-batch-b".to_string(),
IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
acceptance_criteria: Some(Some("- [ ] Still pending".to_string())),
transition_comment: Some("B is not actually ready".to_string()),
..Default::default()
},
),
];
storage
.update_issues_atomically(&updates, "tester")
.unwrap_err();
for id in ["bd-batch-a", "bd-batch-b"] {
let issue = storage.get_issue(id).unwrap().unwrap();
assert_eq!(issue.status, Status::InProgress);
assert!(issue.acceptance_criteria.is_none());
assert!(storage.get_comments(id).unwrap().is_empty());
}
}
#[cfg(unix)]
fn existing_namespace_sidecars(db_path: &Path) -> Vec<PathBuf> {
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES
.iter()
.map(|suffix| database_sidecar_path(db_path, suffix))
.filter(|path| path.is_file())
.collect()
}
#[cfg(unix)]
fn directory_bytes_and_modes(dir: &Path) -> BTreeMap<String, (Vec<u8>, u32)> {
use std::os::unix::fs::PermissionsExt;
let mut snapshot = BTreeMap::new();
for entry in fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let metadata = fs::symlink_metadata(entry.path()).unwrap();
let bytes = if metadata.is_file() {
fs::read(entry.path()).unwrap()
} else if metadata.file_type().is_symlink() {
fs::read_link(entry.path())
.unwrap()
.as_os_str()
.to_string_lossy()
.as_bytes()
.to_vec()
} else {
Vec::new()
};
snapshot.insert(
entry.file_name().to_string_lossy().into_owned(),
(bytes, metadata.permissions().mode()),
);
}
snapshot
}
fn synthetic_wal_header(salts: (u32, u32)) -> (Vec<u8>, (u32, u32)) {
const PAGE_SIZE: u32 = 512;
let mut header = [0_u8; 32];
header[..4].copy_from_slice(&0x377f_0682_u32.to_be_bytes());
header[4..8].copy_from_slice(&3_007_000_u32.to_be_bytes());
header[8..12].copy_from_slice(&PAGE_SIZE.to_be_bytes());
header[16..20].copy_from_slice(&salts.0.to_be_bytes());
header[20..24].copy_from_slice(&salts.1.to_be_bytes());
let checksum = wal_checksum(&header[..24], 0, 0, false);
header[24..28].copy_from_slice(&checksum.0.to_be_bytes());
header[28..32].copy_from_slice(&checksum.1.to_be_bytes());
(header.to_vec(), checksum)
}
fn append_synthetic_wal_frame(
wal: &mut Vec<u8>,
running_checksum: &mut (u32, u32),
page_number: u32,
database_size: u32,
salts: (u32, u32),
page_one_user_version: Option<u32>,
) {
const PAGE_SIZE: usize = 512;
let mut frame = vec![0_u8; 24 + PAGE_SIZE];
frame[..4].copy_from_slice(&page_number.to_be_bytes());
frame[4..8].copy_from_slice(&database_size.to_be_bytes());
frame[8..12].copy_from_slice(&salts.0.to_be_bytes());
frame[12..16].copy_from_slice(&salts.1.to_be_bytes());
if let Some(user_version) = page_one_user_version {
assert_eq!(page_number, 1, "only page one carries user_version");
frame[24..40].copy_from_slice(b"SQLite format 3\0");
frame[40..42].copy_from_slice(&512_u16.to_be_bytes());
frame[84..88].copy_from_slice(&user_version.to_be_bytes());
}
let after_header = wal_checksum(&frame[..8], running_checksum.0, running_checksum.1, false);
let checksum = wal_checksum(&frame[24..], after_header.0, after_header.1, false);
frame[16..20].copy_from_slice(&checksum.0.to_be_bytes());
frame[20..24].copy_from_slice(&checksum.1.to_be_bytes());
*running_checksum = checksum;
wal.extend_from_slice(&frame);
}
/// GitHub #403: the lock-free fast path must not chmod a namespace
/// sidecar. It declines without changing any bytes or mode, then the same
/// database opens and heals only after its exact family authority is held.
#[cfg(unix)]
#[test]
fn lock_free_fast_open_is_nonmutating_before_authority_gated_sidecar_heal() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
let issue = make_issue(
"bd-ns",
"sidecar mode",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let loosened = existing_namespace_sidecars(&db_path);
for sidecar in &loosened {
fs::set_permissions(sidecar, fs::Permissions::from_mode(0o664)).unwrap();
}
assert!(
!loosened.is_empty(),
"expected fsqlite namespace sidecars beside {}",
db_path.display()
);
let database_before = fs::read(&db_path).unwrap();
let sidecars_before: Vec<_> = loosened
.iter()
.map(|sidecar| {
(
sidecar.clone(),
fs::read(sidecar).unwrap(),
fs::metadata(sidecar).unwrap().permissions().mode(),
)
})
.collect();
assert!(
SqliteStorage::open_current_read_only(&db_path)
.expect("read-only fast preflight")
.is_none(),
"a mode repair must route through the authority-acquiring fallback"
);
assert_eq!(fs::read(&db_path).unwrap(), database_before);
for (sidecar, bytes, mode) in &sidecars_before {
assert_eq!(fs::read(sidecar).unwrap(), *bytes);
assert_eq!(fs::metadata(sidecar).unwrap().permissions().mode(), *mode);
}
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let storage =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect("authority-gated open must repair the sidecar mode");
assert!(storage.get_issue("bd-ns").unwrap().is_some());
drop(storage);
for sidecar in loosened {
let mode = fs::metadata(&sidecar).unwrap().permissions().mode();
assert_eq!(
mode & 0o077,
0,
"sidecar {} still group/other accessible (mode {:04o})",
sidecar.display(),
mode & 0o7777
);
}
}
/// Seed a checkpointed database with one issue and return it closed.
#[cfg(unix)]
fn seed_closed_database(db_path: &Path, issue_id: &str) {
let mut storage = SqliteStorage::open(db_path).unwrap();
let issue = make_issue(
issue_id,
"sidecar mode fixture",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
/// Resets the chmod-ignored emulation when a test unwinds.
#[cfg(unix)]
struct ChmodIgnoredGuard;
#[cfg(unix)]
impl Drop for ChmodIgnoredGuard {
fn drop(&mut self) {
SqliteStorage::set_namespace_sidecar_chmod_ignored_for_test(false);
}
}
/// GitHub #491: on a mount that accepts chmod and ignores it (WSL drvfs
/// without `metadata`, FAT/exFAT) the authority-gated repair cannot make
/// a sidecar owner-only. Both the writable opener and the pending-saga
/// verdict must then refuse with the named filesystem limitation and its
/// remedy — not the engine's bare `unable to open database file`, and not
/// a misattributed "changed identity" or "state is unknown" refusal — and
/// leave every byte alone. Once the mount honours chmod again the same
/// database heals and opens.
#[cfg(unix)]
#[test]
fn permissionless_mount_emulation_refuses_with_named_limitation_then_heals() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
seed_closed_database(&db_path, "bd-drvfs");
let sidecars = existing_namespace_sidecars(&db_path);
assert_eq!(sidecars.len(), 2, "both namespace sidecars exist");
for sidecar in &sidecars {
fs::set_permissions(sidecar, fs::Permissions::from_mode(0o777)).unwrap();
}
let bytes_before: Vec<Vec<u8>> = sidecars.iter().map(|s| fs::read(s).unwrap()).collect();
let database_before = fs::read(&db_path).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let _guard = ChmodIgnoredGuard;
SqliteStorage::set_namespace_sidecar_chmod_ignored_for_test(true);
let assert_named_limitation = |error: &BeadsError, lane: &str| {
assert!(
matches!(error, BeadsError::Config(_)),
"{lane}: expected a configuration error, got {error:?}"
);
let text = error.to_string();
for needle in [
"-fsqlite-ns-gate",
"reports mode 0777",
"does not persist POSIX permission bits",
"owner-only (0600)",
"/mnt/<drive>",
"`metadata`",
"/etc/wsl.conf",
"Linux filesystem",
] {
assert!(
text.contains(needle),
"{lane}: missing {needle:?} in {text}"
);
}
assert!(
!text.contains("unable to open database file"),
"{lane}: must not surface the bare engine error: {text}"
);
};
let error =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect_err("chmod-ignoring mount cannot satisfy the engine");
assert_named_limitation(&error, "writable open");
let error = SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect_err("verdict cannot be reached on a chmod-ignoring mount");
assert_named_limitation(&error, "pending-saga verdict");
assert!(
!error.to_string().contains("state is unknown"),
"the limitation must not be reported as verdict uncertainty: {error}"
);
assert_eq!(fs::read(&db_path).unwrap(), database_before);
for (sidecar, before) in sidecars.iter().zip(&bytes_before) {
assert_eq!(&fs::read(sidecar).unwrap(), before, "{}", sidecar.display());
assert_eq!(
fs::metadata(sidecar).unwrap().permissions().mode() & 0o777,
0o777,
"emulated mount keeps its mask on {}",
sidecar.display()
);
}
// The mount honours chmod again: the same authority heals and opens.
SqliteStorage::set_namespace_sidecar_chmod_ignored_for_test(false);
assert!(matches!(
SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("verdict after the repair"),
PendingSyncMergeInspection::Absent
));
let storage =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect("healed sidecars open");
assert!(storage.get_issue("bd-drvfs").unwrap().is_some());
for sidecar in &sidecars {
assert_eq!(
fs::metadata(sidecar).unwrap().permissions().mode() & 0o077,
0
);
}
}
/// GitHub #403 / #491: the engine reports every namespace-sidecar refusal
/// as `unable to open database file: '<sidecar>'`. br explains the actual
/// cause after the fact — filesystem mask on a sidecar the engine just
/// created, a pre-existing loose mode, extra hard links — and leaves any
/// other engine error untouched.
#[cfg(unix)]
#[test]
fn engine_sidecar_refusal_is_explained_by_cause() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
fs::write(&db_path, b"not inspected").unwrap();
// The database's mode is the baseline the advice compares against;
// pin it so the test does not depend on the host umask.
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o644)).unwrap();
let gate = database_sidecar_path(&db_path, "-fsqlite-ns-gate");
let cannot_open = || fsqlite_error::FrankenError::CannotOpen { path: gate.clone() };
// Created by the engine during the failed open, bits imposed by the
// mount: the named filesystem limitation.
fs::write(&gate, b"").unwrap();
fs::set_permissions(&gate, fs::Permissions::from_mode(0o777)).unwrap();
let explained = explain_engine_open_error(&db_path, &["-fsqlite-ns-gate"], cannot_open());
assert!(matches!(explained, BeadsError::Config(_)), "{explained:?}");
let text = explained.to_string();
assert!(
text.contains("immediately after fsqlite created it with mode 0600"),
"{text}"
);
assert!(
text.contains("does not persist POSIX permission bits"),
"{text}"
);
assert!(text.contains("/etc/wsl.conf"), "{text}");
// Pre-existing loose mode: a repairable policy violation.
let explained = explain_engine_open_error(&db_path, &[], cannot_open());
assert!(matches!(explained, BeadsError::Config(_)), "{explained:?}");
let text = explained.to_string();
assert!(text.contains("has mode 0777"), "{text}");
assert!(text.contains("owner-only (0600)"), "{text}");
assert!(
text.contains("looser than the database file (mode 0644)"),
"{text}"
);
assert!(text.contains("br doctor --repair"), "{text}");
assert!(!text.contains("does not persist"), "{text}");
// A database-bounded exposure on an existing sidecar: the advice names
// the engine rule instead of calling the mode a violation.
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o777)).unwrap();
let explained = explain_engine_open_error(&db_path, &[], cannot_open());
let text = explained.to_string();
assert!(
text.contains("grants nothing beyond the database file"),
"{text}"
);
assert!(text.contains("FrankenSQLite"), "{text}");
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o644)).unwrap();
// No database beside the sidecar: nothing to bound against.
let orphan_dir = TempDir::new().unwrap();
let orphan_db = orphan_dir.path().join("beads.db");
let orphan_gate = database_sidecar_path(&orphan_db, "-fsqlite-ns-gate");
fs::write(&orphan_gate, b"").unwrap();
fs::set_permissions(&orphan_gate, fs::Permissions::from_mode(0o644)).unwrap();
let explained = explain_engine_open_error(
&orphan_db,
&[],
fsqlite_error::FrankenError::CannotOpen {
path: orphan_gate.clone(),
},
);
let text = explained.to_string();
assert!(
text.contains("no regular database file beside it"),
"{text}"
);
// Extra hard link, owner-only mode.
fs::set_permissions(&gate, fs::Permissions::from_mode(0o600)).unwrap();
let alias = temp.path().join("gate-alias");
fs::hard_link(&gate, &alias).unwrap();
let explained = explain_engine_open_error(&db_path, &[], cannot_open());
let text = explained.to_string();
assert!(text.contains("has 2 hard links"), "{text}");
fs::remove_file(&alias).unwrap();
// Owner-only, single link, owned by us: nothing to explain — the
// engine error is returned unchanged.
let explained = explain_engine_open_error(&db_path, &[], cannot_open());
assert!(
matches!(
&explained,
BeadsError::Database(fsqlite_error::FrankenError::CannotOpen { path }) if *path == gate
),
"{explained:?}"
);
// A `CannotOpen` naming the database itself, and a non-open error.
let explained = explain_engine_open_error(
&db_path,
&["-fsqlite-ns-gate"],
fsqlite_error::FrankenError::CannotOpen {
path: db_path.clone(),
},
);
assert!(
matches!(explained, BeadsError::Database(_)),
"{explained:?}"
);
let explained = explain_engine_open_error(
&db_path,
&["-fsqlite-ns-gate"],
fsqlite_error::FrankenError::Busy,
);
assert!(
matches!(
explained,
BeadsError::Database(fsqlite_error::FrankenError::Busy)
),
"{explained:?}"
);
}
/// GitHub #491: a standalone snapshot (database copied without the engine's
/// writer sidecars) reads through the lock-free read-only lane without
/// creating a sidecar, which is why `br count` works where `br create`
/// fails on a permission-less mount.
#[cfg(unix)]
#[test]
fn snapshot_without_namespace_sidecars_reads_without_creating_them() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
seed_closed_database(&db_path, "bd-snapshot");
for sidecar in existing_namespace_sidecars(&db_path) {
fs::remove_file(sidecar).unwrap();
}
assert!(existing_namespace_sidecars(&db_path).is_empty());
assert_eq!(absent_namespace_sidecar_suffixes(&db_path).len(), 2);
let storage = SqliteStorage::open_current_read_only(&db_path)
.expect("read-only open of a sidecar-less snapshot")
.expect("current schema");
assert!(storage.get_issue("bd-snapshot").unwrap().is_some());
drop(storage);
assert!(
existing_namespace_sidecars(&db_path).is_empty(),
"the read-only lane must not create namespace sidecars"
);
}
/// GitHub #491, opt-in against a real permission-less mount: point
/// `BR_PERMISSIONLESS_FS_DIR` at a writable directory on a WSL Windows
/// drive without `metadata`, or a FAT volume mounted with a 0777 mask, and
/// a standalone snapshot copied there must read, and must either write or
/// refuse with the named limitation — never with the bare engine error.
#[cfg(unix)]
#[test]
#[allow(clippy::too_many_lines)]
fn permissionless_mount_snapshot_reads_and_write_is_supported_or_explained() {
use std::os::unix::fs::PermissionsExt;
let Some(root) = std::env::var_os("BR_PERMISSIONLESS_FS_DIR") else {
eprintln!("BR_PERMISSIONLESS_FS_DIR unset; skipping");
return;
};
let seed = TempDir::new().unwrap();
let seed_db = seed.path().join("beads.db");
seed_closed_database(&seed_db, "bd-mount");
let mount_dir = tempfile::Builder::new()
.prefix("br-ns-mask-")
.tempdir_in(root)
.unwrap();
let beads_dir = mount_dir.path().join(".beads");
fs::create_dir(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
fs::copy(&seed_db, &db_path).unwrap();
let probe = beads_dir.join("mode-probe");
fs::write(&probe, b"").unwrap();
fs::set_permissions(&probe, fs::Permissions::from_mode(0o600)).unwrap();
let probe_mode = fs::metadata(&probe).unwrap().permissions().mode();
assert_ne!(
probe_mode & 0o077,
0,
"{} does not look like a permission-less mount (probe mode {:04o})",
beads_dir.display(),
probe_mode & 0o7777
);
// Read lane: sidecar-less snapshot reads.
let storage = SqliteStorage::open_current_read_only(&db_path)
.expect("read-only open on the mount")
.expect("current schema");
assert!(storage.get_issue("bd-mount").unwrap().is_some());
drop(storage);
// Write lane: supported (engine tolerates the mount mask) or refused
// with the actionable explanation.
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
match SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
{
Ok(mut storage) => {
let issue = make_issue(
"bd-mount-write",
"write on a permission-less mount",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
drop(storage);
drop(authority);
// The first write created the sidecars, which now report the
// mount mask. Every later command must still work: a second
// write through a fresh authority, and the lock-free read lane.
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
assert!(matches!(
SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("verdict with mount-mask sidecars present"),
PendingSyncMergeInspection::Absent
));
let mut storage = SqliteStorage::open_with_timeout_under_write_authority(
&db_path,
Some(50),
&authority,
)
.expect("second write open with mount-mask sidecars present");
let issue = make_issue(
"bd-mount-write-2",
"second write on a permission-less mount",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
drop(storage);
drop(authority);
let storage = SqliteStorage::open_current_read_only(&db_path)
.expect("read-only open with mount-mask sidecars present")
.expect("read-only lane admits mount-mask sidecars");
assert!(storage.get_issue("bd-mount-write-2").unwrap().is_some());
}
Err(error) => {
let text = error.to_string();
assert!(matches!(error, BeadsError::Config(_)), "{error:?}");
assert!(
text.contains("does not persist POSIX permission bits"),
"{text}"
);
assert!(text.contains("/etc/wsl.conf"), "{text}");
assert!(!text.contains("unable to open database file"), "{text}");
}
}
}
/// GitHub #491: br's copy of the engine's sidecar exposure rule and its
/// engine-version gate must agree with the FrankenSQLite actually linked.
/// A same-GID family at 0664/0664 is database-bounded: the engine opens it
/// iff br believes it does; a sidecar looser than its database is refused
/// by every engine version.
#[cfg(unix)]
#[test]
fn sidecar_exposure_rule_matches_the_linked_engine() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
seed_closed_database(&db_path, "bd-parity");
let sidecars = existing_namespace_sidecars(&db_path);
assert_eq!(sidecars.len(), 2);
let set = |path: &Path, mode: u32| {
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
};
let gid = fs::metadata(&db_path).unwrap().gid();
set(&db_path, 0o664);
for sidecar in &sidecars {
set(sidecar, 0o664);
}
assert!(sidecar_exposure_is_database_bounded(
0o100_664, gid, &db_path
));
let bounded_open = Connection::open(db_path.to_string_lossy().into_owned());
assert_eq!(
bounded_open.is_ok(),
engine_accepts_database_bounded_sidecar_exposure(),
"br's engine gate ({:?}) disagrees with the linked engine's verdict on a database-bounded sidecar exposure: {:?}",
option_env!("BR_FSQLITE_VERSION"),
bounded_open.err()
);
drop(bounded_open);
set(&db_path, 0o644);
assert!(!sidecar_exposure_is_database_bounded(
0o100_664, gid, &db_path
));
let looser_open = Connection::open(db_path.to_string_lossy().into_owned());
assert!(
matches!(
looser_open,
Err(fsqlite_error::FrankenError::CannotOpen { .. })
),
"a sidecar looser than its database must be refused by the engine: {looser_open:?}"
);
for sidecar in &sidecars {
set(sidecar, 0o600);
}
Connection::open(db_path.to_string_lossy().into_owned())
.expect("owner-only sidecars are always admitted");
}
/// The per-class, GID-aware bound (frankensqlite 64e75a742) and the
/// engine-version parser.
#[cfg(unix)]
#[test]
fn database_bounded_exposure_rule_table() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("rule.db");
fs::write(&db_path, b"db").unwrap();
let gid = fs::metadata(&db_path).unwrap().gid();
let other_gid = gid.wrapping_add(1);
let set_db = |mode: u32| {
fs::set_permissions(&db_path, fs::Permissions::from_mode(mode)).unwrap();
};
// Owner-only is always bounded, even without a database.
assert!(sidecar_exposure_is_database_bounded(
0o100_600,
gid,
&temp.path().join("missing.db")
));
assert!(!sidecar_exposure_is_database_bounded(
0o100_644,
gid,
&temp.path().join("missing.db")
));
for (db_mode, sidecar_mode, sidecar_gid, bounded) in [
(0o644, 0o644, gid, true),
(0o644, 0o640, gid, true),
(0o644, 0o664, gid, false),
(0o664, 0o664, gid, true),
(0o777, 0o777, gid, true),
(0o600, 0o640, gid, false),
// Different group: the sidecar's group bits must be covered by the
// database's group AND other bits (a sidecar-group member may be
// "other" to the database).
(0o664, 0o664, other_gid, false),
(0o644, 0o644, other_gid, true),
(0o646, 0o664, other_gid, false),
(0o666, 0o664, other_gid, true),
(0o777, 0o777, other_gid, true),
] {
set_db(db_mode);
assert_eq!(
sidecar_exposure_is_database_bounded(
0o100_000 | sidecar_mode,
sidecar_gid,
&db_path
),
bounded,
"db {db_mode:04o} sidecar {sidecar_mode:04o} same_gid={}",
sidecar_gid == gid
);
}
assert_eq!(parse_engine_version("0.3.16"), Some((0, 3, 16)));
assert_eq!(parse_engine_version("0.3.18-rc.1"), Some((0, 3, 18)));
assert_eq!(parse_engine_version("1.0.0+build"), Some((1, 0, 0)));
assert_eq!(parse_engine_version("0.3"), None);
assert_eq!(parse_engine_version("x.y.z"), None);
assert!((0, 4, 0) > ENGINE_DATABASE_BOUNDED_SIDECAR_EXPOSURE_SINCE);
assert!((0, 3, 17) < ENGINE_DATABASE_BOUNDED_SIDECAR_EXPOSURE_SINCE);
}
/// GitHub #491, hermetic: a family whose database and sidecars all report
/// the mount mask (0777) on a mount that ignores chmod. With an engine that
/// admits database-bounded exposure, br must neither repair nor refuse —
/// writes, the verdict and the lock-free read lane all proceed. With an
/// older engine br refuses up front and names both the mount and the engine
/// rule instead of the bare engine error.
#[cfg(unix)]
#[test]
#[allow(clippy::too_many_lines)]
fn mount_mask_family_follows_the_linked_engine_rule() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
seed_closed_database(&db_path, "bd-mask");
let sidecars = existing_namespace_sidecars(&db_path);
assert_eq!(sidecars.len(), 2);
for path in sidecars.iter().chain(std::iter::once(&db_path)) {
fs::set_permissions(path, fs::Permissions::from_mode(0o777)).unwrap();
}
let _guard = ChmodIgnoredGuard;
SqliteStorage::set_namespace_sidecar_chmod_ignored_for_test(true);
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
assert!(
namespace_sidecar_mode_repair_witnesses(&db_path)
.unwrap()
.iter()
.all(|witness| witness.database_bounded),
"every sidecar in a mount-mask family is database-bounded"
);
if engine_accepts_database_bounded_sidecar_exposure() {
assert!(!namespace_sidecar_mode_repair_required(&db_path).unwrap());
assert!(matches!(
SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect("verdict without repair"),
PendingSyncMergeInspection::Absent
));
let mut storage = SqliteStorage::open_with_timeout_under_write_authority(
&db_path,
Some(50),
&authority,
)
.expect("write open admits a mount-mask family");
let issue = make_issue(
"bd-mask-2",
"write under a mount mask",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
drop(storage);
let storage = SqliteStorage::open_current_read_only(&db_path)
.expect("read-only lane")
.expect("read-only lane admits a mount-mask family");
assert!(storage.get_issue("bd-mask-2").unwrap().is_some());
} else {
assert!(namespace_sidecar_mode_repair_required(&db_path).unwrap());
assert!(
SqliteStorage::open_current_read_only(&db_path)
.expect("read-only lane")
.is_none(),
"the lock-free lane declines so the authority path can classify"
);
for (lane, error) in [
(
"write open",
SqliteStorage::open_with_timeout_under_write_authority(
&db_path,
Some(50),
&authority,
)
.expect_err("older engine: refused"),
),
(
"verdict",
SqliteStorage::inspect_pending_sync_merge_under_authority(&db_path, &authority)
.expect_err("older engine: refused"),
),
] {
assert!(matches!(error, BeadsError::Config(_)), "{lane}: {error:?}");
let text = error.to_string();
for needle in [
"does not persist POSIX permission bits",
"grants nothing beyond the database file",
"FrankenSQLite 0.3.18+",
"/etc/wsl.conf",
] {
assert!(
text.contains(needle),
"{lane}: missing {needle:?} in {text}"
);
}
assert!(
!text.contains("unable to open database file"),
"{lane}: {text}"
);
}
}
for path in sidecars.iter().chain(std::iter::once(&db_path)) {
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o777,
"{} keeps the mount mask",
path.display()
);
}
}
/// A namespace-sidecar path swap after its no-follow handle is open must
/// fail the immediate path/handle identity fence before fchmod. The symlink
/// target therefore keeps its deliberately permissive mode.
#[cfg(unix)]
#[test]
fn authority_gated_sidecar_heal_rejects_symlink_swap_before_chmod() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("sidecar_swap.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let sidecars = existing_namespace_sidecars(&db_path);
let target = sidecars
.first()
.expect("fsqlite namespace sidecar fixture")
.clone();
fs::set_permissions(&target, fs::Permissions::from_mode(0o664)).unwrap();
let victim = temp.path().join("outside-family-victim");
fs::write(&victim, b"must-not-be-chmodded").unwrap();
fs::set_permissions(&victim, fs::Permissions::from_mode(0o644)).unwrap();
let victim_mode_before = fs::metadata(&victim).unwrap().permissions().mode();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
SqliteStorage::arm_namespace_sidecar_swap_after_open_for_test(victim.clone());
let error =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect_err("a sidecar symlink swap must fail closed");
assert!(
error.to_string().contains("changed identity"),
"unexpected sidecar swap error: {error}"
);
assert!(
fs::symlink_metadata(&target)
.unwrap()
.file_type()
.is_symlink(),
"the hook must install the causal symlink swap"
);
assert_eq!(
fs::metadata(&victim).unwrap().permissions().mode(),
victim_mode_before,
"the swapped-in symlink target must never be chmodded"
);
let retained = database_sidecar_path(&target, ".test-retained-after-open-symlink-swap");
assert_eq!(
fs::metadata(retained).unwrap().permissions().mode() & 0o077,
0o064,
"the handle-bound original must not be chmodded before the path identity fence"
);
}
/// The family-wide type/mode preflight must inspect every namespace
/// sidecar before chmodding the first repair candidate.
#[cfg(unix)]
#[test]
fn sidecar_heal_preflights_later_symlink_before_first_chmod() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("sidecar_family_preflight.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let first = database_sidecar_path(
&db_path,
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES[0],
);
let second = database_sidecar_path(
&db_path,
crate::config::FSQLITE_NAMESPACE_SIDECAR_SUFFIXES[1],
);
if !first.is_file() {
fs::write(&first, b"first-sidecar").unwrap();
}
fs::set_permissions(&first, fs::Permissions::from_mode(0o664)).unwrap();
let first_mode_before = fs::metadata(&first).unwrap().permissions().mode();
let victim = temp.path().join("later-sidecar-symlink-victim");
fs::write(&victim, b"outside-family").unwrap();
if second.exists() {
let retained = database_sidecar_path(&second, ".test-retained-before-preflight");
fs::rename(&second, retained).unwrap();
}
std::os::unix::fs::symlink(&victim, &second).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let error =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect_err("a later unsafe sidecar must abort before any chmod");
assert!(
error
.to_string()
.contains("unsafe fsqlite namespace sidecar"),
"unexpected family preflight error: {error}"
);
assert_eq!(
fs::metadata(&first).unwrap().permissions().mode(),
first_mode_before,
"the first permissive sidecar was chmodded before the later symlink was rejected"
);
}
/// GitHub #399: a status move that `workflow.transitions` forbids must be
/// rejected by the storage preflight itself (the chokepoint every batch
/// close routes through), before any row in the batch is mutated. An
/// explicit `--bypass-policy` reason still gets through.
#[test]
#[allow(clippy::too_many_lines)]
fn forbidden_workflow_transition_is_rejected_for_whole_batch_before_mutation() {
let mut transitions = std::collections::BTreeMap::new();
transitions.insert("open".to_string(), vec!["in_progress".to_string()]);
transitions.insert("in_progress".to_string(), vec!["closed".to_string()]);
let workflow = crate::close_policy::Workflow {
strict: true,
statuses: vec![
"open".to_string(),
"in_progress".to_string(),
"closed".to_string(),
],
transitions,
..Default::default()
};
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_policy(workflow);
let allowed = make_issue(
"bd-trans-allowed",
"already in progress",
Status::InProgress,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&allowed, "tester").unwrap();
let forbidden = make_issue(
"bd-trans-forbidden",
"still open",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&forbidden, "tester").unwrap();
let closes = vec![
(
"bd-trans-allowed".to_string(),
IssueUpdate {
status: Some(Status::Closed),
..Default::default()
},
),
(
"bd-trans-forbidden".to_string(),
IssueUpdate {
status: Some(Status::Closed),
..Default::default()
},
),
];
let error = storage
.update_issues_atomically(&closes, "tester")
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("workflow.transitions") && message.contains("'open'"),
"error should name the rejected transition: {message}"
);
// Neither issue moved: the whole batch is preflighted before mutation.
assert_eq!(
storage
.get_issue("bd-trans-allowed")
.unwrap()
.unwrap()
.status,
Status::InProgress
);
assert_eq!(
storage
.get_issue("bd-trans-forbidden")
.unwrap()
.unwrap()
.status,
Status::Open
);
// The allowed leg on its own still commits.
storage
.update_issues_atomically(&closes[..1], "tester")
.unwrap();
assert_eq!(
storage
.get_issue("bd-trans-allowed")
.unwrap()
.unwrap()
.status,
Status::Closed
);
// `--bypass-policy` (a recorded bypass reason) still gets through.
storage
.update_issues_atomically(
&[(
"bd-trans-forbidden".to_string(),
IssueUpdate {
status: Some(Status::Closed),
workflow_policy_bypass_reason: Some("incident response".to_string()),
..Default::default()
},
)],
"tester",
)
.unwrap();
assert_eq!(
storage
.get_issue("bd-trans-forbidden")
.unwrap()
.unwrap()
.status,
Status::Closed
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn gate_pass_is_scoped_to_status_revision_and_history_is_preserved() {
let mut workflow = crate::close_policy::Workflow {
strict: true,
..Default::default()
};
workflow.gates.insert(
"in_review -> closed".to_string(),
crate::close_policy::GateRule {
require_all: vec![crate::close_policy::GateSpec::Named("ci_green".to_string())],
..Default::default()
},
);
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_policy(workflow);
let issue = make_issue(
"bd-cycle",
"review cycle",
Status::Custom("in_review".to_string()),
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
let first = storage
.record_scoped_gate_result(
"bd-cycle",
"in_review",
0,
"closed",
"ci_green",
"ci",
true,
Some("cycle one"),
"ci-bot",
)
.unwrap();
assert_eq!(first.status_revision, 0);
for status in ["rework", "In_Review"] {
storage
.update_issue(
"bd-cycle",
&IssueUpdate {
status: Some(Status::Custom(status.to_string())),
..Default::default()
},
"tester",
)
.unwrap();
}
assert!(
storage
.get_scoped_gate_results("bd-cycle", "in_review", "closed")
.unwrap()
.is_empty(),
"the prior review cycle must not authorize the new status revision"
);
let error = storage
.update_issue(
"bd-cycle",
&IssueUpdate {
status: Some(Status::Closed),
..Default::default()
},
"tester",
)
.unwrap_err();
let BeadsError::PolicyViolation { violations, .. } = error else {
panic!("expected stale-gate policy violation, got {error:?}");
};
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("stale status revision"));
assert_eq!(
violations[0].detail.as_ref().unwrap()["reason"],
"stale_status_revision"
);
assert_eq!(
storage
.get_issue("bd-cycle")
.unwrap()
.unwrap()
.status
.as_str(),
"in_review"
);
let history = storage.get_gate_result_history("bd-cycle").unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].note.as_deref(), Some("cycle one"));
let stale_report = storage
.record_scoped_gate_result(
"bd-cycle",
"in_review",
first.status_revision,
"closed",
"ci_green",
"ci",
true,
Some("raced stale report"),
"ci-bot",
)
.unwrap_err();
assert!(stale_report.to_string().contains("retry"));
assert_eq!(
storage.get_gate_result_history("bd-cycle").unwrap().len(),
1,
"a raced report must not append history for the wrong attempt"
);
let current_revision = storage.status_revision("bd-cycle").unwrap();
let second = storage
.record_scoped_gate_result(
"bd-cycle",
"In_Review",
current_revision,
"closed",
"ci_green",
"ci",
true,
Some("cycle two"),
"ci-bot",
)
.unwrap();
assert!(second.status_revision > first.status_revision);
let closed = storage
.update_issue(
"bd-cycle",
&IssueUpdate {
status: Some(Status::Closed),
..Default::default()
},
"tester",
)
.unwrap();
assert_eq!(closed.status, Status::Closed);
assert_eq!(
storage.get_gate_result_history("bd-cycle").unwrap().len(),
2
);
// GitHub #466: every scoped report must also mirror the latest
// (gate, provider) verdict into the current-state `gate_results`
// table. Two reports from the same provider collapse into one row
// carrying the most recent verdict/note.
let mirror = storage
.conn
.query("SELECT issue_id, gate, provider, passed, note FROM gate_results")
.unwrap();
assert_eq!(
mirror.len(),
1,
"gate_results must hold exactly one current-state row per (issue, gate, provider)"
);
let row = &mirror[0];
assert_eq!(row.get(0).and_then(SqliteValue::as_text), Some("bd-cycle"));
assert_eq!(row.get(1).and_then(SqliteValue::as_text), Some("ci_green"));
assert_eq!(row.get(2).and_then(SqliteValue::as_text), Some("ci"));
assert_eq!(row.get(3).and_then(SqliteValue::as_integer), Some(1));
assert_eq!(
row.get(4).and_then(SqliteValue::as_text),
Some("cycle two"),
"a re-report must overwrite the mirror row with the latest verdict"
);
}
#[test]
fn workflow_policy_bypass_is_atomic_and_audited() {
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_policy(required_review_fields_workflow());
let issue = make_issue(
"bd-bypass",
"emergency transition",
Status::InProgress,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.update_issue(
"bd-bypass",
&IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
workflow_policy_bypass_reason: Some("incident response".to_string()),
..Default::default()
},
"operator",
)
.unwrap();
let events = storage.get_events("bd-bypass", 0).unwrap();
let bypass = events
.iter()
.find(|event| {
event.event_type == EventType::Custom("workflow_policy_bypassed".to_string())
})
.expect("bypass event");
assert_eq!(bypass.actor, "operator");
assert_eq!(bypass.comment.as_deref(), Some("incident response"));
}
#[test]
fn workflow_capacity_create_reaches_hard_limit_then_rolls_back_next_insert() {
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let now = Utc::now();
storage
.create_issue(
&make_issue(
"bd-cap-create-1",
"first",
Status::InProgress,
1,
None,
now,
None,
),
"tester",
)
.unwrap();
let error = storage
.create_issue(
&make_issue(
"bd-cap-create-2",
"second",
Status::InProgress,
1,
None,
now,
None,
),
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 1);
assert_eq!(violation.prospective, 2);
assert_eq!(violation.hard_limit, 1);
assert!(storage.get_issue("bd-cap-create-2").unwrap().is_none());
}
#[test]
fn workflow_capacity_rejected_update_preserves_original_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue(
"bd-cap-active",
"active",
Status::InProgress,
1,
None,
now,
None,
),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue("bd-cap-open", "open", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let update = IssueUpdate {
status: Some(Status::InProgress),
title: Some("must roll back".to_string()),
..IssueUpdate::default()
};
let error = storage
.update_issue("bd-cap-open", &update, "tester")
.unwrap_err();
assert!(matches!(error, BeadsError::WorkflowCapacityExceeded { .. }));
let unchanged = storage.get_issue("bd-cap-open").unwrap().unwrap();
assert_eq!(unchanged.status, Status::Open);
assert_eq!(unchanged.title, "open");
}
#[test]
fn workflow_capacity_allows_transitions_that_drain_an_overfull_status() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-cap-over-1", "bd-cap-over-2"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let update = IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
};
storage
.update_issue("bd-cap-over-1", &update, "tester")
.unwrap();
assert_eq!(
storage.get_issue("bd-cap-over-1").unwrap().unwrap().status,
Status::Closed
);
}
#[test]
fn workflow_capacity_group_composes_multiple_custom_statuses() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-cap-group-1", Status::InProgress),
("bd-cap-group-2", Status::Custom("in_review".to_string())),
("bd-cap-group-3", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.groups.insert(
"active_work".to_string(),
crate::close_policy::CapacityGroup {
statuses: vec!["in_progress".to_string(), "in_review".to_string()],
soft: None,
hard: Some(2),
},
);
storage.set_workflow_capacity_policy(policy);
let update = IssueUpdate {
status: Some(Status::Custom("in_review".to_string())),
..IssueUpdate::default()
};
let error = storage
.update_issue("bd-cap-group-3", &update, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.capacity_kind, "group");
assert_eq!(violation.capacity_name, "active_work");
assert_eq!(violation.current, 2);
assert_eq!(violation.prospective, 3);
}
#[test]
fn workflow_capacity_admission_blocks_fresh_work_but_allows_rework_transition() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-cap-review", Status::Custom("in_review".to_string())),
("bd-cap-fresh", Status::Open),
("bd-cap-rework", Status::Custom("rework".to_string())),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
let mut policy = crate::close_policy::CapacityPolicy::default();
policy
.admission
.push(crate::close_policy::CapacityAdmissionRule {
name: "drain_review".to_string(),
transitions: crate::close_policy::CapacityTransitionMatcher {
from: vec!["open".to_string()],
to: vec!["in_progress".to_string()],
},
require_below: crate::close_policy::CapacityRequirements {
statuses: std::iter::once(("in_review".to_string(), 1)).collect(),
groups: BTreeMap::new(),
},
});
storage.set_workflow_capacity_policy(policy);
let update = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let error = storage
.update_issue("bd-cap-fresh", &update, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.capacity_kind, "admission_status");
assert_eq!(violation.capacity_name, "in_review");
storage
.update_issue("bd-cap-rework", &update, "tester")
.unwrap();
assert_eq!(
storage.get_issue("bd-cap-rework").unwrap().unwrap().status,
Status::InProgress
);
}
#[test]
fn workflow_capacity_concurrent_last_slot_has_exactly_one_winner() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("capacity-race.db");
let now = Utc::now();
{
let mut setup = SqliteStorage::open(&db_path).unwrap();
for id in ["bd-cap-race-1", "bd-cap-race-2"] {
setup
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
}
}
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let mut handles = Vec::new();
for id in ["bd-cap-race-1", "bd-cap-race-2"] {
let db_path = db_path.clone();
let barrier = std::sync::Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let update = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
barrier.wait();
storage.update_issue(id, &update, "tester")
}));
}
let results: Vec<Result<Issue>> = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(
results
.iter()
.filter(|result| matches!(result, Err(BeadsError::WorkflowCapacityExceeded { .. })))
.count(),
1
);
let storage = SqliteStorage::open(&db_path).unwrap();
let active = storage
.list_issues(&ListFilters::default())
.unwrap()
.into_iter()
.filter(|issue| issue.status == Status::InProgress)
.count();
assert_eq!(active, 1);
}
#[test]
fn workflow_capacity_atomic_batch_rejection_rolls_back_every_issue_and_field() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-cap-batch-active", Status::InProgress),
("bd-cap-batch-open-1", Status::Open),
("bd-cap-batch-open-2", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 2));
let update = IssueUpdate {
title: Some("must roll back".to_string()),
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let batch = [
("bd-cap-batch-open-1".to_string(), update.clone()),
("bd-cap-batch-open-2".to_string(), update),
];
let error = storage
.update_issues_atomically(&batch, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 1);
assert_eq!(violation.prospective, 3);
assert_eq!(violation.issue_id, "bd-cap-batch-open-1");
for id in ["bd-cap-batch-open-1", "bd-cap-batch-open-2"] {
let issue = storage.get_issue(id).unwrap().unwrap();
assert_eq!(issue.status, Status::Open);
assert_eq!(issue.title, id);
}
}
#[test]
fn workflow_capacity_batch_preflight_allows_admission_before_matching_drain() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue(
"bd-cap-swap-active",
"active",
Status::InProgress,
1,
None,
now,
None,
),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue("bd-cap-swap-open", "open", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
// Admission intentionally appears first. Sequential evaluation would
// reject it at 2/1 even though the batch's final occupancy remains 1.
let batch = [
(
"bd-cap-swap-open".to_string(),
IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
),
(
"bd-cap-swap-active".to_string(),
IssueUpdate {
status: Some(Status::Open),
..IssueUpdate::default()
},
),
];
storage.update_issues_atomically(&batch, "tester").unwrap();
assert_eq!(
storage
.get_issue("bd-cap-swap-open")
.unwrap()
.unwrap()
.status,
Status::InProgress
);
assert_eq!(
storage
.get_issue("bd-cap-swap-active")
.unwrap()
.unwrap()
.status,
Status::Open
);
}
#[test]
fn workflow_capacity_soft_warnings_are_structured_deterministic_and_consumed() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-cap-soft-1", "bd-cap-soft-2"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
}
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.statuses.insert(
"in_progress".to_string(),
crate::close_policy::CapacityLimit {
soft: Some(2),
hard: Some(3),
},
);
policy.groups.insert(
"active_work".to_string(),
crate::close_policy::CapacityGroup {
statuses: vec!["in_progress".to_string()],
soft: Some(1),
hard: None,
},
);
storage.set_workflow_capacity_policy(policy);
let update = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
storage
.update_issues_atomically(
&[
("bd-cap-soft-1".to_string(), update.clone()),
("bd-cap-soft-2".to_string(), update),
],
"tester",
)
.unwrap();
let warnings = storage.take_capacity_warnings();
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].capacity_kind, "status");
assert_eq!(warnings[0].capacity_name, "in_progress");
assert_eq!(warnings[0].issue_id, "bd-cap-soft-1");
assert_eq!(warnings[0].current, 0);
assert_eq!(warnings[0].prospective, 2);
assert_eq!(warnings[0].soft_limit, 2);
assert_eq!(warnings[0].hard_limit, Some(3));
assert_eq!(warnings[1].capacity_kind, "group");
assert_eq!(warnings[1].capacity_name, "active_work");
assert!(storage.take_capacity_warnings().is_empty());
// `all` counting must not advertise hierarchy evidence.
assert_eq!(warnings[0].counting_mode, "all");
assert!(warnings[0].aggregate_parents_excluded.is_none());
}
/// Build the epic -> parent -> {child A, child B} shape from the GitHub
/// #384 hierarchy example, with `prefix`-scoped IDs.
fn exemptable_hard_status_capacity(
status: &str,
hard: u32,
) -> crate::close_policy::CapacityPolicy {
let mut policy = hard_status_capacity(status, hard);
policy.exemptions.providers = vec!["operator".to_string()];
policy
}
#[test]
fn capacity_exemption_admits_beyond_hard_limit_and_separates_counted_exempt_totals() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-ex-active", Status::InProgress),
("bd-ex-hotfix", Status::Open),
("bd-ex-normal", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage.set_workflow_capacity_policy(exemptable_hard_status_capacity("in_progress", 1));
storage
.grant_capacity_exemption(
"bd-ex-hotfix",
"status",
"in_progress",
"operator",
"externally mandated hotfix",
Some(now + chrono::Duration::hours(2)),
"human-lead",
)
.unwrap();
// The exempted issue enters a full capacity without consuming a slot.
let update = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
storage
.update_issue("bd-ex-hotfix", &update, "tester")
.unwrap();
// A normal issue is still rejected, and the evidence separates the
// counted total from the exempt total (GitHub #384: "Reports show
// counted and exempt totals separately").
let error = storage
.update_issue("bd-ex-normal", &update, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 1, "exempt issue must not be counted");
assert_eq!(violation.prospective, 2);
assert_eq!(violation.exempt, Some(1));
assert!(
violation.to_string().contains("exempt: 1"),
"human evidence missing exempt total: {violation}"
);
}
#[test]
fn capacity_exemption_ends_when_issue_leaves_the_applicable_status() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-exl-active", Status::InProgress),
("bd-exl-hotfix", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage.set_workflow_capacity_policy(exemptable_hard_status_capacity("in_progress", 1));
storage
.grant_capacity_exemption(
"bd-exl-hotfix",
"status",
"in_progress",
"operator",
"one admission only",
None,
"human-lead",
)
.unwrap();
let enter = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
storage
.update_issue("bd-exl-hotfix", &enter, "tester")
.unwrap();
// Leaving the applicable status ends the exemption, audited, in the
// same transaction as the departure.
let leave = IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
};
storage
.update_issue("bd-exl-hotfix", &leave, "tester")
.unwrap();
let records = storage
.list_capacity_exemptions(Some("bd-exl-hotfix"))
.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].state, "left_status");
let history = storage
.get_capacity_exemption_history(Some("bd-exl-hotfix"))
.unwrap();
assert_eq!(
history.last().map(|entry| entry.action.as_str()),
Some("left_status")
);
// Re-entry counts again: without a fresh grant the full capacity
// rejects it.
let error = storage
.update_issue("bd-exl-hotfix", &enter, "tester")
.unwrap_err();
assert!(matches!(error, BeadsError::WorkflowCapacityExceeded { .. }));
}
#[test]
fn capacity_exemption_expires_lazily_with_audited_record_and_counts_again() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-exp-active", Status::InProgress),
("bd-exp-hotfix", Status::Open),
("bd-exp-normal", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage.set_workflow_capacity_policy(exemptable_hard_status_capacity("in_progress", 1));
storage
.grant_capacity_exemption(
"bd-exp-hotfix",
"status",
"in_progress",
"operator",
"will expire",
Some(now + chrono::Duration::hours(2)),
"human-lead",
)
.unwrap();
let enter = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
storage
.update_issue("bd-exp-hotfix", &enter, "tester")
.unwrap();
// Simulate the clock passing the expiry.
storage
.with_connection_write_transaction(|conn| {
conn.execute_with_params(
"UPDATE capacity_exemptions SET expires_at = ? WHERE issue_id = ?",
&[
SqliteValue::from((now - chrono::Duration::hours(1)).to_rfc3339()),
SqliteValue::from("bd-exp-hotfix"),
],
)?;
Ok(())
})
.unwrap();
// Expired exemptions count again: the hotfix now occupies a slot, so
// the next admission sees current=2 with no exempt total.
let error = storage
.update_issue("bd-exp-normal", &enter, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 2);
assert_eq!(violation.exempt, None);
// The rejected mutation rolled its own expiry marking back with it,
// but listing derives `expired` from the record without mutating.
let records = storage
.list_capacity_exemptions(Some("bd-exp-hotfix"))
.unwrap();
assert_eq!(records[0].state, "expired");
assert!(records[0].ended_at.is_none(), "read paths never mutate");
// The first *committed* observation persists the audited expire
// record: draining the overfull status is allowed and commits.
let drain = IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
};
storage
.update_issue("bd-exp-active", &drain, "tester")
.unwrap();
let records = storage
.list_capacity_exemptions(Some("bd-exp-hotfix"))
.unwrap();
assert_eq!(records[0].state, "expired");
assert!(records[0].ended_at.is_some());
let history = storage
.get_capacity_exemption_history(Some("bd-exp-hotfix"))
.unwrap();
assert_eq!(
history.last().map(|entry| entry.action.as_str()),
Some("expire")
);
}
#[test]
fn capacity_exemption_effect_is_withdrawn_when_provider_leaves_policy() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-exw-active", Status::InProgress),
("bd-exw-hotfix", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage.set_workflow_capacity_policy(exemptable_hard_status_capacity("in_progress", 1));
storage
.grant_capacity_exemption(
"bd-exw-hotfix",
"status",
"in_progress",
"operator",
"granted before the policy change",
None,
"human-lead",
)
.unwrap();
// Removing the provider from policy silently withdraws its grants
// without touching the audit history.
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let enter = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let error = storage
.update_issue("bd-exw-hotfix", &enter, "tester")
.unwrap_err();
assert!(matches!(error, BeadsError::WorkflowCapacityExceeded { .. }));
}
#[test]
fn capacity_exemption_grant_enforces_expiry_policy() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-exg", "issue", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let mut policy = exemptable_hard_status_capacity("in_progress", 1);
policy.exemptions.require_expiry = true;
policy.exemptions.max_ttl_seconds = Some(3600);
storage.set_workflow_capacity_policy(policy);
let no_expiry = storage
.grant_capacity_exemption(
"bd-exg",
"status",
"in_progress",
"operator",
"missing expiry",
None,
"human-lead",
)
.unwrap_err();
assert!(no_expiry.to_string().contains("require_expiry"));
let too_long = storage
.grant_capacity_exemption(
"bd-exg",
"status",
"in_progress",
"operator",
"beyond the cap",
Some(now + chrono::Duration::hours(48)),
"human-lead",
)
.unwrap_err();
assert!(too_long.to_string().contains("max_ttl_seconds"));
let in_the_past = storage
.grant_capacity_exemption(
"bd-exg",
"status",
"in_progress",
"operator",
"already over",
Some(now - chrono::Duration::hours(1)),
"human-lead",
)
.unwrap_err();
assert!(in_the_past.to_string().contains("future"));
storage
.grant_capacity_exemption(
"bd-exg",
"status",
"in_progress",
"operator",
"within the cap",
Some(now + chrono::Duration::minutes(30)),
"human-lead",
)
.unwrap();
}
#[test]
fn capacity_exemption_applies_to_admission_observations_of_the_same_status() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-exa-review", Status::Custom("in_review".to_string())),
("bd-exa-next", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
let mut policy = crate::close_policy::CapacityPolicy::default();
policy
.admission
.push(crate::close_policy::CapacityAdmissionRule {
name: "drain_review_before_starting".to_string(),
transitions: crate::close_policy::CapacityTransitionMatcher {
from: vec!["open".to_string()],
to: vec!["in_progress".to_string()],
},
require_below: crate::close_policy::CapacityRequirements {
statuses: std::collections::BTreeMap::from([("in_review".to_string(), 1)]),
groups: std::collections::BTreeMap::new(),
},
});
policy.exemptions.providers = vec!["operator".to_string()];
storage.set_workflow_capacity_policy(policy);
let enter = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let error = storage
.update_issue("bd-exa-next", &enter, "tester")
.unwrap_err();
assert!(matches!(error, BeadsError::WorkflowCapacityExceeded { .. }));
// Exempting the long-lived review item from the observed queue lets
// fresh work start without draining it.
storage
.grant_capacity_exemption(
"bd-exa-review",
"status",
"in_review",
"operator",
"awaiting an external regulatory decision",
None,
"human-lead",
)
.unwrap();
storage
.update_issue("bd-exa-next", &enter, "tester")
.unwrap();
}
#[test]
fn capacity_exemption_under_leaf_work_excludes_only_counting_members() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-lwx-parent", Status::InProgress),
("bd-lwx-child", Status::InProgress),
("bd-lwx-new", Status::Open),
("bd-lwx-extra", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "tester")
.unwrap();
}
storage
.add_dependency("bd-lwx-child", "bd-lwx-parent", "parent-child", "tester")
.unwrap();
let mut policy = exemptable_hard_status_capacity("in_progress", 1);
policy.counting.hierarchy = crate::close_policy::CapacityCountingMode::LeafWork;
storage.set_workflow_capacity_policy(policy);
// leaf_work counts only the child; exempting it frees the slot while
// the parent stays aggregate-excluded (the exempt issue remains
// active for suppression, so an exemption can never raise a count).
storage
.grant_capacity_exemption(
"bd-lwx-child",
"status",
"in_progress",
"operator",
"external dependency stalls this leaf",
None,
"human-lead",
)
.unwrap();
let enter = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
storage
.update_issue("bd-lwx-new", &enter, "tester")
.unwrap();
let error = storage
.update_issue("bd-lwx-extra", &enter, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 1);
assert_eq!(violation.exempt, Some(1));
assert_eq!(violation.aggregate_parents_excluded, Some(1));
}
fn seed_capacity_hierarchy(storage: &mut SqliteStorage, prefix: &str, statuses: [Status; 4]) {
let now = Utc::now();
let ids = [
format!("{prefix}-epic"),
format!("{prefix}-parent"),
format!("{prefix}-child-a"),
format!("{prefix}-child-b"),
];
for (id, status) in ids.iter().zip(statuses) {
let mut issue = make_issue(id, id, status, 1, None, now, None);
if issue.status == Status::Closed {
issue.closed_at = Some(now);
}
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_dependency(&ids[1], &ids[0], "parent-child", "tester")
.unwrap();
storage
.add_dependency(&ids[2], &ids[1], "parent-child", "tester")
.unwrap();
storage
.add_dependency(&ids[3], &ids[1], "parent-child", "tester")
.unwrap();
}
fn leaf_work_group_policy(
name: &str,
statuses: &[&str],
hard: u32,
) -> crate::close_policy::CapacityPolicy {
let mut policy = crate::close_policy::CapacityPolicy {
counting: crate::close_policy::CapacityCounting {
hierarchy: crate::close_policy::CapacityCountingMode::LeafWork,
weights: crate::close_policy::CapacityWeights::default(),
},
..crate::close_policy::CapacityPolicy::default()
};
policy.groups.insert(
name.to_string(),
crate::close_policy::CapacityGroup {
statuses: statuses.iter().map(|s| (*s).to_string()).collect(),
soft: None,
hard: Some(hard),
},
);
policy
}
#[test]
fn capacity_leaf_work_counts_the_github_384_example_as_two_slots() {
// Epic: in_progress / Parent: in_progress / A: in_progress /
// B: in_review. The issue body specifies this consumes two slots
// under leaf_work, not four.
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-lw",
[
Status::InProgress,
Status::InProgress,
Status::InProgress,
Status::Custom("in_review".to_string()),
],
);
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress", "in_review"],
2,
));
// A fresh leaf admitted into the group makes it 3 > hard 2.
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-lw-solo", "solo", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let error = storage
.update_issue(
"bd-lw-solo",
&IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.counting_mode, "leaf_work");
assert_eq!(violation.current, 2, "epic and parent are aggregates");
assert_eq!(violation.prospective, 3);
// Epic and parent are active but excluded as aggregates.
assert_eq!(violation.aggregate_parents_excluded, Some(2));
}
#[test]
fn capacity_leaf_work_starts_counting_a_parent_when_its_last_child_leaves() {
// Draining the final active descendant must not free a slot: the
// active parent stops being an aggregate and begins counting itself.
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-lw2",
[
Status::Closed,
Status::InProgress,
Status::InProgress,
Status::Closed,
],
);
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress"],
1,
));
// Only child A counts right now; the parent is an aggregate.
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-lw2-solo", "solo", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let admit = IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let blocked = storage
.update_issue("bd-lw2-solo", &admit, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = blocked else {
panic!("unexpected capacity error: {blocked:?}");
};
assert_eq!(violation.current, 1);
assert_eq!(violation.aggregate_parents_excluded, Some(1));
// Closing child A leaves the parent as the sole counted issue, so
// the count stays 1 and admitting a fresh issue still fails.
storage
.update_issue(
"bd-lw2-child-a",
&IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
"tester",
)
.unwrap();
let still_blocked = storage
.update_issue("bd-lw2-solo", &admit, "tester")
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = still_blocked else {
panic!("unexpected capacity error: {still_blocked:?}");
};
assert_eq!(
violation.current, 1,
"parent must begin counting once its last active descendant leaves"
);
assert_eq!(violation.aggregate_parents_excluded, Some(0));
}
#[test]
fn capacity_leaf_work_enforces_an_increase_caused_only_by_a_drain() {
// A child shared by two active parents is the one case where a
// transition that only *leaves* the capacity still raises the count:
// closing it turns both parents from aggregates into counted work.
// The hard limit must still be enforced.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-shared-p1", "bd-shared-p2", "bd-shared-child"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
// Two parents for one child: reachable through import, not through
// `--parent`, which replaces the existing edge.
storage
.conn
.execute(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at)
VALUES ('bd-shared-child', 'bd-shared-p1', 'parent-child', '2026-01-01T00:00:00Z'),
('bd-shared-child', 'bd-shared-p2', 'parent-child', '2026-01-01T00:00:00Z')",
)
.unwrap();
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress"],
1,
));
// Only the shared child counts today: both parents are aggregates.
let error = storage
.update_issue(
"bd-shared-child",
&IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 1);
assert_eq!(
violation.prospective, 2,
"both parents begin counting once the shared child leaves"
);
assert_eq!(violation.aggregate_parents_excluded, Some(0));
assert_eq!(
storage
.get_issue("bd-shared-child")
.unwrap()
.unwrap()
.status,
Status::InProgress,
"rejection must roll back"
);
}
#[test]
fn capacity_leaf_work_ignores_blocks_edges() {
// Only parent-child edges participate: a `blocks` edge between two
// active leaves must never suppress one of them.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-lwb-1", "bd-lwb-2"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
storage
.add_dependency("bd-lwb-2", "bd-lwb-1", "blocks", "tester")
.unwrap();
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress"],
2,
));
storage
.create_issue(
&make_issue("bd-lwb-3", "third", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let error = storage
.update_issue(
"bd-lwb-3",
&IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 2, "blocks edges must not aggregate");
assert_eq!(violation.aggregate_parents_excluded, Some(0));
}
#[test]
fn capacity_roots_counts_the_highest_active_ancestor() {
// Same tree as the leaf_work example: under `roots` the epic is the
// single counted work stream.
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-roots",
[
Status::InProgress,
Status::InProgress,
Status::InProgress,
Status::InProgress,
],
);
let mut policy = leaf_work_group_policy("active_work", &["in_progress"], 1);
policy.counting.hierarchy = crate::close_policy::CapacityCountingMode::Roots;
storage.set_workflow_capacity_policy(policy);
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-roots-solo", "solo", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let error = storage
.update_issue(
"bd-roots-solo",
&IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.counting_mode, "roots");
assert_eq!(violation.current, 1, "one active work stream");
assert_eq!(violation.prospective, 2);
assert_eq!(violation.aggregate_parents_excluded, Some(3));
}
#[test]
fn capacity_weighted_applies_issue_and_type_weights() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
let mut epic = make_issue("bd-w-epic", "epic", Status::InProgress, 1, None, now, None);
epic.issue_type = IssueType::Epic;
storage.create_issue(&epic, "tester").unwrap();
for id in ["bd-w-1", "bd-w-2"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
let mut weights = crate::close_policy::CapacityWeights {
default: Some(1),
..crate::close_policy::CapacityWeights::default()
};
// An epic represents no independent execution; bd-w-2 is double-weight.
weights.types.insert("epic".to_string(), 0);
weights.issues.insert("bd-w-2".to_string(), 2);
let mut policy = crate::close_policy::CapacityPolicy {
counting: crate::close_policy::CapacityCounting {
hierarchy: crate::close_policy::CapacityCountingMode::Weighted,
weights,
},
..crate::close_policy::CapacityPolicy::default()
};
policy.statuses.insert(
"in_progress".to_string(),
crate::close_policy::CapacityLimit {
soft: None,
hard: Some(3),
},
);
storage.set_workflow_capacity_policy(policy);
// Weighted current = 0 (epic) + 1 + 2 = 3; one more unit exceeds 3.
storage
.create_issue(
&make_issue("bd-w-3", "third", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let error = storage
.update_issue(
"bd-w-3",
&IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.counting_mode, "weighted");
assert_eq!(violation.current, 3);
assert_eq!(violation.prospective, 4);
assert!(
violation.aggregate_parents_excluded.is_none(),
"weighted counting has no aggregate exclusion"
);
}
#[test]
fn capacity_weighted_counts_a_created_issue_before_its_row_exists() {
// Creation must resolve the new issue's weight from the requested
// type, not from a row that does not exist yet.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
let mut weights = crate::close_policy::CapacityWeights::default();
weights.types.insert("epic".to_string(), 5);
let mut policy = crate::close_policy::CapacityPolicy {
counting: crate::close_policy::CapacityCounting {
hierarchy: crate::close_policy::CapacityCountingMode::Weighted,
weights,
},
..crate::close_policy::CapacityPolicy::default()
};
policy.statuses.insert(
"in_progress".to_string(),
crate::close_policy::CapacityLimit {
soft: None,
hard: Some(4),
},
);
storage.set_workflow_capacity_policy(policy);
let mut epic = make_issue("bd-wc-epic", "epic", Status::InProgress, 1, None, now, None);
epic.issue_type = IssueType::Epic;
let error = storage.create_issue(&epic, "tester").unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(violation.current, 0);
assert_eq!(violation.prospective, 5);
assert!(storage.get_issue("bd-wc-epic").unwrap().is_none());
}
#[test]
fn capacity_hierarchy_counts_every_member_of_a_dependency_cycle() {
// Imported data can contain a parent-child cycle. Condensing the
// cycle into one component keeps its active members visible instead
// of letting them cancel each other out.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-cyc-1", "bd-cyc-2"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
// Bypass the cycle guard the way a JSONL import would.
storage
.conn
.execute(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at)
VALUES ('bd-cyc-1', 'bd-cyc-2', 'parent-child', '2026-01-01T00:00:00Z'),
('bd-cyc-2', 'bd-cyc-1', 'parent-child', '2026-01-01T00:00:00Z')",
)
.unwrap();
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress"],
2,
));
storage
.create_issue(
&make_issue("bd-cyc-3", "third", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
let error = storage
.update_issue(
"bd-cyc-3",
&IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("unexpected capacity error: {error:?}");
};
assert_eq!(
violation.current, 2,
"both cycle members must remain counted"
);
assert_eq!(violation.aggregate_parents_excluded, Some(0));
}
#[test]
fn capacity_leaf_work_batch_is_evaluated_on_the_final_state() {
// A batch that activates a parent and closes its only active child
// is capacity-neutral under leaf_work regardless of request order.
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-lwbatch",
[
Status::Closed,
Status::Open,
Status::InProgress,
Status::Closed,
],
);
storage.set_workflow_capacity_policy(leaf_work_group_policy(
"active_work",
&["in_progress"],
1,
));
storage
.update_issues_atomically(
&[
(
"bd-lwbatch-parent".to_string(),
IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
),
(
"bd-lwbatch-child-a".to_string(),
IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
),
],
"tester",
)
.expect("capacity-neutral hierarchy swap must be admitted");
assert_eq!(
storage
.get_issue("bd-lwbatch-parent")
.unwrap()
.unwrap()
.status,
Status::InProgress
);
}
fn scoped_status_capacity(
scope: &str,
status: &str,
soft: Option<u32>,
hard: Option<u32>,
) -> crate::close_policy::CapacityPolicy {
let mut scope_policy = crate::close_policy::CapacityScopePolicy::default();
scope_policy.statuses.insert(
status.to_string(),
crate::close_policy::CapacityLimit { soft, hard },
);
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.scopes.insert(scope.to_string(), scope_policy);
policy
}
fn to_in_progress() -> IssueUpdate {
IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
}
}
fn expect_capacity_violation(
error: &BeadsError,
) -> &crate::close_policy::WorkflowCapacityViolation {
match error {
BeadsError::WorkflowCapacityExceeded { violation } => violation,
other => panic!("expected a workflow capacity violation, got: {other}"),
}
}
#[test]
fn capacity_scope_actor_limits_each_actor_partition_independently() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-sca-1", "bd-sca-2", "bd-sca-3", "bd-sca-4"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"actor",
"in_progress",
None,
Some(2),
));
for id in ["bd-sca-1", "bd-sca-2"] {
storage
.update_issues_atomically(&[(id.to_string(), to_in_progress())], "alice")
.expect("alice is under her cap");
}
let error = storage
.update_issues_atomically(&[("bd-sca-3".to_string(), to_in_progress())], "alice")
.unwrap_err();
let violation = expect_capacity_violation(&error);
assert_eq!(violation.scope, "actor");
assert_eq!(violation.scope_key.as_deref(), Some("alice"));
assert_eq!(violation.current, 2);
assert_eq!(violation.prospective, 3);
assert_eq!(
violation.policy_path,
"workflow.capacity.scopes.actor.statuses.in_progress"
);
assert_eq!(
storage.get_issue("bd-sca-3").unwrap().unwrap().status,
Status::Open,
"rejected transition must not modify issue state"
);
// A different actor has an independent partition.
storage
.update_issues_atomically(&[("bd-sca-4".to_string(), to_in_progress())], "bob")
.expect("bob's partition is empty");
}
#[test]
fn capacity_scope_finish_and_claim_swap_is_scope_neutral() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-scs-1", "bd-scs-2"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"actor",
"in_progress",
None,
Some(1),
));
storage
.update_issues_atomically(&[("bd-scs-1".to_string(), to_in_progress())], "alice")
.unwrap();
// One batch: alice releases her slot and claims another issue.
storage
.update_issues_atomically(
&[
(
"bd-scs-1".to_string(),
IssueUpdate {
status: Some(Status::Open),
..IssueUpdate::default()
},
),
("bd-scs-2".to_string(), to_in_progress()),
],
"alice",
)
.expect("a scope-neutral swap must be admitted at the cap");
}
#[test]
fn capacity_scope_assignee_keys_on_prospective_assignee_and_skips_unassigned() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-scg-1", "bd-scg-2", "bd-scg-3", "bd-scg-4"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"assignee",
"in_progress",
None,
Some(1),
));
let claim_for = |assignee: &str| IssueUpdate {
status: Some(Status::InProgress),
assignee: Some(Some(assignee.to_string())),
..IssueUpdate::default()
};
storage
.update_issues_atomically(&[("bd-scg-1".to_string(), claim_for("bob"))], "op")
.expect("bob's first claim fits");
let error = storage
.update_issues_atomically(&[("bd-scg-2".to_string(), claim_for("bob"))], "op")
.unwrap_err();
let violation = expect_capacity_violation(&error);
assert_eq!(violation.scope, "assignee");
assert_eq!(violation.scope_key.as_deref(), Some("bob"));
storage
.update_issues_atomically(&[("bd-scg-3".to_string(), claim_for("carol"))], "op")
.expect("carol's partition is independent");
// No prospective assignee → the assignee scope is inapplicable.
storage
.update_issues_atomically(&[("bd-scg-4".to_string(), to_in_progress())], "op")
.expect("unassigned transitions are not subject to the assignee scope");
}
#[test]
fn capacity_scope_harness_and_session_key_on_attribution_and_skip_when_absent() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-sch-1", "bd-sch-2", "bd-sch-3"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"harness",
"in_progress",
None,
Some(1),
));
storage.set_pending_event_attribution(EventAttribution::new(
None,
Some("swarm-h1"),
None,
None,
));
storage
.update_issues_atomically(&[("bd-sch-1".to_string(), to_in_progress())], "op")
.expect("first harness claim fits");
storage.set_pending_event_attribution(EventAttribution::new(
None,
Some("swarm-h1"),
None,
None,
));
let error = storage
.update_issues_atomically(&[("bd-sch-2".to_string(), to_in_progress())], "op")
.unwrap_err();
let violation = expect_capacity_violation(&error);
assert_eq!(violation.scope, "harness");
assert_eq!(violation.scope_key.as_deref(), Some("swarm-h1"));
// No harness attribution → the harness scope is inapplicable. The
// staged attribution deliberately survives the failed mutation above
// (post-recovery retry semantics), so clear it first.
let _ = storage.take_pending_event_attribution();
storage
.update_issues_atomically(&[("bd-sch-3".to_string(), to_in_progress())], "op")
.expect("attribution-free transitions skip the harness scope");
// Session scope behaves identically, keyed on the session value.
let mut storage = SqliteStorage::open_memory().unwrap();
for id in ["bd-scn-1", "bd-scn-2"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"session",
"in_progress",
None,
Some(1),
));
storage.set_pending_event_attribution(EventAttribution::new(
None,
None,
None,
Some("sess-9"),
));
storage
.update_issues_atomically(&[("bd-scn-1".to_string(), to_in_progress())], "op")
.unwrap();
storage.set_pending_event_attribution(EventAttribution::new(
None,
None,
None,
Some("sess-9"),
));
let error = storage
.update_issues_atomically(&[("bd-scn-2".to_string(), to_in_progress())], "op")
.unwrap_err();
assert_eq!(
expect_capacity_violation(&error).scope_key.as_deref(),
Some("sess-9")
);
}
#[test]
fn capacity_scope_subtree_counts_by_root_ancestor() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-sct-root", "bd-sct-a", "bd-sct-b", "bd-sct-other"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage
.add_dependency("bd-sct-a", "bd-sct-root", "parent-child", "seed")
.unwrap();
storage
.add_dependency("bd-sct-b", "bd-sct-root", "parent-child", "seed")
.unwrap();
storage.set_workflow_capacity_policy(scoped_status_capacity(
"subtree",
"in_progress",
None,
Some(1),
));
storage
.update_issues_atomically(&[("bd-sct-a".to_string(), to_in_progress())], "op")
.expect("first active leaf in the subtree fits");
let error = storage
.update_issues_atomically(&[("bd-sct-b".to_string(), to_in_progress())], "op")
.unwrap_err();
let violation = expect_capacity_violation(&error);
assert_eq!(violation.scope, "subtree");
assert_eq!(violation.scope_key.as_deref(), Some("bd-sct-root"));
// An issue outside the subtree has its own root partition.
storage
.update_issues_atomically(&[("bd-sct-other".to_string(), to_in_progress())], "op")
.expect("a different subtree is unaffected");
}
#[test]
fn capacity_scope_exemption_frees_the_scoped_slot() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for (id, status) in [
("bd-sce-held", Status::InProgress),
("bd-sce-next", Status::Open),
] {
storage
.create_issue(&make_issue(id, id, status, 1, None, now, None), "alice")
.unwrap();
}
let mut policy = scoped_status_capacity("actor", "in_progress", None, Some(1));
policy.exemptions.providers = vec!["operator".to_string()];
storage.set_workflow_capacity_policy(policy);
// Alice occupies her only slot; without an exemption the claim fails.
let error = storage
.update_issues_atomically(&[("bd-sce-next".to_string(), to_in_progress())], "alice")
.unwrap_err();
assert_eq!(
expect_capacity_violation(&error).scope_key.as_deref(),
Some("alice")
);
storage
.grant_capacity_exemption(
"bd-sce-held",
"status",
"in_progress",
"operator",
"externally blocked long-runner",
Some(now + chrono::Duration::hours(2)),
"human-lead",
)
.unwrap();
let violation_free = storage
.update_issues_atomically(&[("bd-sce-next".to_string(), to_in_progress())], "alice")
.expect("an exempted issue frees its scoped slot too");
drop(violation_free);
}
#[test]
fn capacity_scope_soft_limit_warns_with_scope_evidence() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-scw-1", "one", Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
storage.set_workflow_capacity_policy(scoped_status_capacity(
"actor",
"in_progress",
Some(1),
None,
));
storage
.update_issues_atomically(&[("bd-scw-1".to_string(), to_in_progress())], "alice")
.expect("soft limits never reject");
let warnings = storage.take_capacity_warnings();
assert_eq!(
warnings.len(),
1,
"exactly one scoped warning: {warnings:?}"
);
assert_eq!(warnings[0].scope, "actor");
assert_eq!(warnings[0].scope_key.as_deref(), Some("alice"));
assert_eq!(
warnings[0].policy_path,
"workflow.capacity.scopes.actor.statuses.in_progress"
);
assert!(
warnings[0].to_string().contains("for 'alice'"),
"human text names the partition key: {}",
warnings[0]
);
}
#[test]
fn capacity_scope_repository_entry_composes_with_top_level_limits() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-scr-1", "bd-scr-2"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
storage.set_workflow_capacity_policy(scoped_status_capacity(
"repository",
"in_progress",
None,
Some(1),
));
storage
.update_issues_atomically(&[("bd-scr-1".to_string(), to_in_progress())], "alice")
.unwrap();
// The repository scope ignores the acting partition: a different
// actor is still bound by the shared limit.
let error = storage
.update_issues_atomically(&[("bd-scr-2".to_string(), to_in_progress())], "bob")
.unwrap_err();
let violation = expect_capacity_violation(&error);
assert_eq!(violation.scope, "repository");
assert_eq!(violation.scope_key, None);
assert_eq!(
violation.policy_path,
"workflow.capacity.scopes.repository.statuses.in_progress"
);
}
#[test]
fn capacity_occupancy_records_the_admitting_attribution() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-occ-1", "one", Status::Open, 1, None, now, None),
"creator",
)
.unwrap();
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-7"),
Some("swarm-h1"),
Some("opus-4"),
Some("sess-1"),
));
storage
.update_issues_atomically(&[("bd-occ-1".to_string(), to_in_progress())], "alice")
.unwrap();
let row = storage
.conn
.query_row_with_params(
"SELECT actor, agent_name, harness, session \
FROM capacity_occupancy WHERE issue_id = ?",
&[SqliteValue::from("bd-occ-1")],
)
.expect("occupancy row exists after a status transition");
let text = |index: usize| {
row.get(index)
.and_then(SqliteValue::as_text)
.map(ToString::to_string)
};
assert_eq!(text(0).as_deref(), Some("alice"));
assert_eq!(text(1).as_deref(), Some("agent-7"));
assert_eq!(text(2).as_deref(), Some("swarm-h1"));
assert_eq!(text(3).as_deref(), Some("sess-1"));
}
/// GitHub #391: the cycle report must agree with the add-time gate.
/// A `related` edge is never cycle-checked on insertion, so it must not
/// be counted by `br dep cycles` either; the containment-induced
/// rejection of a descendant's blocks-edge stays (documented design).
#[test]
fn dependency_cycles_agree_with_add_time_blocking_semantics() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in [
"bd-391-e",
"bd-391-s",
"bd-391-e2",
"bd-391-h",
"bd-391-r",
"bd-391-a",
"bd-391-m",
] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
// Containment: E ── S ── E2 (parent-child rows are child -> parent).
storage
.add_dependency("bd-391-s", "bd-391-e", "parent-child", "seed")
.unwrap();
storage
.add_dependency("bd-391-e2", "bd-391-s", "parent-child", "seed")
.unwrap();
// Blocks chains reaching the epic: H -> E, R -> H, A -> E, M -> A.
for (from, to) in [
("bd-391-h", "bd-391-e"),
("bd-391-r", "bd-391-h"),
("bd-391-a", "bd-391-e"),
("bd-391-m", "bd-391-a"),
] {
storage.add_dependency(from, to, "blocks", "seed").unwrap();
}
// Documented containment rule: a descendant's blocks-edge back into
// a chain that reaches the epic is rejected as a cycle.
let error = storage
.add_dependency("bd-391-e2", "bd-391-r", "blocks", "seed")
.unwrap_err();
assert!(matches!(error, BeadsError::DependencyCycle { .. }));
// A `related` edge is accepted unchecked...
storage
.add_dependency("bd-391-e2", "bd-391-m", "related", "seed")
.unwrap();
// ...and must NOT surface as a cycle in any report mode.
assert!(
storage.detect_blocking_cycles().unwrap().is_empty(),
"blocking cycle report must ignore related edges"
);
for blocking_only in [false, true] {
let report = storage
.detect_dependency_cycle_report(blocking_only)
.unwrap();
assert!(
report.active_cycles.is_empty(),
"related edges the add path allowed must not fail the cycle \
report (blocking_only={blocking_only}): {:?}",
report.active_cycles
);
}
// Positive control: a genuine blocking cycle is still detected.
// (Insert via the import-relation path, which does not cycle-check.)
for id in ["bd-391-p", "bd-391-q"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"seed",
)
.unwrap();
}
let cyclic_dep = |issue: &str, on: &str| crate::model::Dependency {
issue_id: issue.to_string(),
depends_on_id: on.to_string(),
dep_type: crate::model::DependencyType::Blocks,
created_at: now,
created_by: None,
metadata: None,
thread_id: None,
};
storage
.sync_dependencies_for_import("bd-391-p", &[cyclic_dep("bd-391-p", "bd-391-q")])
.unwrap();
storage
.sync_dependencies_for_import("bd-391-q", &[cyclic_dep("bd-391-q", "bd-391-p")])
.unwrap();
assert!(
!storage.detect_blocking_cycles().unwrap().is_empty(),
"a genuine blocking cycle must still be reported"
);
}
#[test]
fn workflow_capacity_same_status_update_does_not_affect_capacity() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue("bd-same-1", "held", Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
// The status is already at its hard limit; a same-status update must
// not be treated as a new admission.
let update = IssueUpdate {
status: Some(Status::InProgress),
title: Some("still held".to_string()),
..IssueUpdate::default()
};
storage
.update_issue("bd-same-1", &update, "tester")
.expect("same-status updates do not consume capacity");
assert_eq!(
storage.get_issue("bd-same-1").unwrap().unwrap().title,
"still held"
);
}
#[test]
fn derived_rollup_reports_subtree_status_without_mutating_the_parent() {
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-rollup",
[
Status::Open,
Status::Open,
Status::InProgress,
Status::Closed,
],
);
let epic = storage
.get_issue_details("bd-rollup-epic", false, false, 0)
.unwrap()
.unwrap();
assert_eq!(epic.issue.status, Status::Open, "explicit status is intact");
let rollup = epic.rollup.expect("epic has children");
assert_eq!(rollup.status, "in_progress");
assert_eq!(rollup.descendants.get("in_progress"), Some(&1));
assert_eq!(rollup.descendants.get("open"), Some(&1));
assert_eq!(rollup.descendants.get("closed"), Some(&1));
// A leaf has no children and therefore no rollup.
let leaf = storage
.get_issue_details("bd-rollup-child-a", false, false, 0)
.unwrap()
.unwrap();
assert!(leaf.rollup.is_none());
}
#[test]
fn derived_rollup_terminates_on_a_parent_child_cycle() {
// The subtree walk must not loop on imported cyclic data, and the
// issue itself must never appear among its own descendants.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-rc-1", "bd-rc-2"] {
storage
.create_issue(
&make_issue(id, id, Status::InProgress, 1, None, now, None),
"tester",
)
.unwrap();
}
storage
.conn
.execute(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at)
VALUES ('bd-rc-1', 'bd-rc-2', 'parent-child', '2026-01-01T00:00:00Z'),
('bd-rc-2', 'bd-rc-1', 'parent-child', '2026-01-01T00:00:00Z')",
)
.unwrap();
let rollup = storage
.derived_rollup("bd-rc-1")
.unwrap()
.expect("cyclic parent still has a child");
assert_eq!(rollup.status, "in_progress");
assert_eq!(
rollup.descendants.get("in_progress"),
Some(&1),
"only the other cycle member counts as a descendant"
);
}
#[test]
fn derived_rollup_is_closed_when_every_descendant_is_terminal() {
let mut storage = SqliteStorage::open_memory().unwrap();
seed_capacity_hierarchy(
&mut storage,
"bd-rollup2",
[Status::Open, Status::Closed, Status::Closed, Status::Closed],
);
let rollup = storage
.get_issue_details("bd-rollup2-epic", false, false, 0)
.unwrap()
.unwrap()
.rollup
.expect("epic has children");
assert_eq!(rollup.status, "closed");
assert_eq!(rollup.descendants.get("closed"), Some(&3));
}
#[test]
fn atomic_batch_late_validation_failure_rolls_back_earlier_update() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
for id in ["bd-batch-valid", "bd-batch-invalid"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
}
let batch = [
(
"bd-batch-valid".to_string(),
IssueUpdate {
title: Some("would otherwise commit".to_string()),
..IssueUpdate::default()
},
),
(
"bd-batch-invalid".to_string(),
IssueUpdate {
title: Some(String::new()),
..IssueUpdate::default()
},
),
];
storage
.update_issues_atomically(&batch, "tester")
.unwrap_err();
assert_eq!(
storage.get_issue("bd-batch-valid").unwrap().unwrap().title,
"bd-batch-valid"
);
}
type ReadyTextFields = (
String,
String,
Status,
Priority,
IssueType,
DateTime<Utc>,
DateTime<Utc>,
);
fn ready_text_fields(issue: Issue) -> ReadyTextFields {
(
issue.id,
issue.title,
issue.status,
issue.priority,
issue.issue_type,
issue.created_at,
issue.updated_at,
)
}
fn issue_ids(issues: Vec<Issue>) -> Vec<String> {
issues.into_iter().map(|issue| issue.id).collect()
}
fn ready_summary_projection_fixture() -> SqliteStorage {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 20, 12, 0, 0).unwrap();
let mut ready = make_issue(
"bd-ready-summary",
"Ready summary issue",
Status::Open,
1,
Some("alice"),
created_at,
None,
);
ready.description = Some("Description should stay cold".to_string());
ready.design = Some("Design should stay cold".repeat(128));
ready.acceptance_criteria = Some("AC should stay cold".repeat(128));
ready.notes = Some("Notes should stay cold".repeat(128));
ready.owner = Some("product".to_string());
ready.estimated_minutes = Some(45);
ready.created_by = Some("agent".to_string());
ready.updated_at = created_at + chrono::Duration::minutes(5);
let issues = [
ready,
make_issue(
"bd-ready-summary-other",
"Other ready summary issue",
Status::Open,
2,
None,
created_at + chrono::Duration::minutes(1),
None,
),
make_issue(
"bd-ready-summary-blocker",
"Blocker",
Status::Open,
0,
None,
created_at + chrono::Duration::minutes(2),
None,
),
make_issue(
"bd-ready-summary-blocked",
"Blocked",
Status::Open,
1,
None,
created_at + chrono::Duration::minutes(3),
None,
),
];
for issue in &issues {
storage.create_issue(issue, "tester").unwrap();
}
storage
.add_dependency(
"bd-ready-summary-blocked",
"bd-ready-summary-blocker",
"blocks",
"tester",
)
.unwrap();
storage
}
fn blocker_id_from_ref_for_test(blocker_ref: &str) -> String {
blocker_ref
.rsplit_once(':')
.map_or(blocker_ref, |(prefix, _)| prefix)
.to_string()
}
fn blocked_issue_output_for_test(
(issue, blockers): (Issue, Vec<String>),
) -> BlockedIssueOutput {
BlockedIssueOutput {
blocked_by: blockers
.iter()
.map(|blocker_ref| blocker_id_from_ref_for_test(blocker_ref))
.collect(),
blocked_by_count: blockers.len(),
created_at: issue.created_at,
created_by: issue.created_by,
description: issue.description,
id: issue.id,
issue_type: issue.issue_type,
priority: issue.priority,
status: issue.status,
title: issue.title,
updated_at: issue.updated_at,
}
}
#[test]
fn test_dedupe_export_hash_batch_keeps_last_hash_in_first_position() {
let exports = vec![
("bd-a".to_string(), "hash-a1".to_string()),
("bd-b".to_string(), "hash-b".to_string()),
("bd-a".to_string(), "hash-a2".to_string()),
];
let deduped = SqliteStorage::dedupe_export_hash_batch(&exports);
assert_eq!(
deduped,
vec![
("bd-a".to_string(), "hash-a2".to_string()),
("bd-b".to_string(), "hash-b".to_string()),
]
);
}
fn insert_external_parent_child_dependency(
storage: &SqliteStorage,
external_child: &str,
parent_id: &str,
created_at: DateTime<Utc>,
) {
// Temporarily disable FK checks for the raw INSERT since external
// children do not exist in the local issues table.
storage.conn.execute("PRAGMA foreign_keys = OFF").unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, ?, 'parent-child', ?, ?)",
&[
SqliteValue::from(external_child),
SqliteValue::from(parent_id),
SqliteValue::from(created_at.to_rfc3339()),
SqliteValue::from("tester"),
],
)
.unwrap();
storage.conn.execute("PRAGMA foreign_keys = ON").unwrap();
}
fn insert_parent_child_dependency_for_test(
storage: &SqliteStorage,
child_id: &str,
parent_id: &str,
created_at: DateTime<Utc>,
) {
storage
.conn
.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, ?, 'parent-child', ?, ?)",
&[
SqliteValue::from(child_id),
SqliteValue::from(parent_id),
SqliteValue::from(created_at.to_rfc3339()),
SqliteValue::from("tester"),
],
)
.unwrap();
}
#[test]
fn test_open_memory() {
let storage = SqliteStorage::open_memory();
assert!(storage.is_ok(), "open_memory failed: {:?}", storage.err());
}
/// Regression for #299: `open_memory` is backed by a real temp file (because
/// FrankenSQLite cannot use `:memory:`), and that file plus any WAL/SHM/
/// journal sidecars must be unlinked when the storage is dropped — otherwise
/// `beads_mem_*` files accumulate in `TMPDIR`.
#[test]
fn open_memory_temp_files_removed_on_drop() {
let mut storage = SqliteStorage::open_memory().unwrap();
let db_path = storage
.temp_db_path
.clone()
.expect("open_memory must record its temp db path");
// Perform a mutation so SQLite actually materializes the WAL sidecar,
// exercising the sidecar-cleanup path and not just the base .db file.
let issue = make_issue(
"bd-1",
"tmp leak repro",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
assert!(
db_path.exists(),
"temp db file should exist while storage is open: {}",
db_path.display()
);
drop(storage);
// The base file and every sidecar SQLite may have created must be gone.
let name = db_path
.file_name()
.and_then(|n| n.to_str())
.expect("temp db name");
let leftovers: Vec<PathBuf> = std::iter::once(db_path.clone())
.chain(
[
"-wal",
"-shm",
"-journal",
"-fsqlite-ns-gate",
"-fsqlite-ns-use",
"-wal-cert",
"-wal-cert-head",
".fsqlite-migration-state",
]
.iter()
.map(|s| db_path.with_file_name(format!("{name}{s}"))),
)
.filter(|p| p.exists())
.collect();
assert!(
leftovers.is_empty(),
"temp db files left behind after drop: {leftovers:?}"
);
}
/// A failed `open_memory` (or its drop) must not leave files behind either:
/// `remove_temp_db_files` cleans up the base file and sidecars and tolerates
/// missing files.
#[test]
fn remove_temp_db_files_clears_all_sidecars() {
let dir = TempDir::new().unwrap();
let base = dir.path().join("beads_mem_test_0.db");
// Cover every engine-managed sidecar, not just the classic three: the
// fsqlite namespace / WAL-cert / migration-state files leaked before.
let sidecars = [
"",
"-wal",
"-shm",
"-journal",
"-fsqlite-ns-gate",
"-fsqlite-ns-use",
"-wal-cert",
"-wal-cert-head",
".fsqlite-migration-state",
];
for suffix in sidecars {
fs::write(
dir.path().join(format!("beads_mem_test_0.db{suffix}")),
b"x",
)
.unwrap();
}
remove_temp_db_files(&base);
for suffix in sidecars {
let p = dir.path().join(format!("beads_mem_test_0.db{suffix}"));
assert!(!p.exists(), "should have been removed: {}", p.display());
}
// Idempotent / tolerant of already-missing files.
remove_temp_db_files(&base);
}
#[test]
fn test_create_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = Issue {
id: "bd-1".to_string(),
title: "Test Issue".to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: Utc::now(),
updated_at: Utc::now(),
content_hash: None,
description: None,
design: None,
acceptance_criteria: None,
notes: None,
assignee: None,
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
// Verify it exists (raw query since get_issue not impl yet)
let count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM issues WHERE id = ?",
&[SqliteValue::from("bd-1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(count, 1);
let persisted = storage
.get_issue("bd-1")
.expect("get created issue")
.expect("created issue exists");
let expected_hash = issue.compute_content_hash();
assert_eq!(
persisted.content_hash.as_deref(),
Some(expected_hash.as_str()),
"create_issue should persist the canonical content hash"
);
// Verify event
let event_count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM events WHERE issue_id = ?",
&[SqliteValue::from("bd-1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(event_count, 1);
// Verify dirty
let dirty_count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from("bd-1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(dirty_count, 1);
}
#[test]
fn test_create_issue_rejects_invalid_issue_without_persisting() {
let mut storage = SqliteStorage::open_memory().unwrap();
let invalid = Issue {
id: "bd-invalid-create".to_string(),
title: "x".repeat(501),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: Utc::now(),
updated_at: Utc::now(),
..Issue::default()
};
let error = storage
.create_issue(&invalid, "tester")
.expect_err("invalid issues must be rejected before persistence");
assert!(
matches!(
&error,
BeadsError::Validation { field, reason }
if field == "title" && reason.contains("exceeds 500")
),
"unexpected error: {error:?}"
);
assert!(
storage
.get_issue("bd-invalid-create")
.expect("lookup invalid issue")
.is_none(),
"invalid issue must not be persisted"
);
assert!(
storage
.get_events("bd-invalid-create", 100)
.expect("events")
.is_empty(),
"invalid issue must not emit events"
);
let dirty_ids = storage.get_dirty_issue_ids().expect("dirty marker");
assert!(
!dirty_ids.contains(&"bd-invalid-create".to_string()),
"invalid issue must not be marked dirty"
);
}
#[test]
fn test_get_all_issues_metadata_preserves_custom_status() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue(
"bd-custom",
"Custom status",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.execute_test_sql("UPDATE issues SET status = 'mystery-state' WHERE id = 'bd-custom'")
.unwrap();
let metadata = storage.get_all_issues_metadata().unwrap();
let issue_meta = metadata
.iter()
.find(|meta| meta.id == "bd-custom")
.expect("metadata for bd-custom");
assert_eq!(
issue_meta.status,
Status::Custom("mystery-state".to_string())
);
}
#[test]
fn test_get_all_issues_metadata_errors_on_invalid_updated_at() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue(
"bd-bad-time",
"Bad timestamp",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.execute_test_sql(
"UPDATE issues SET updated_at = 'not-a-timestamp' WHERE id = 'bd-bad-time'",
)
.unwrap();
let err = storage.get_all_issues_metadata().unwrap_err();
assert!(
matches!(&err, BeadsError::Config(message) if message.contains("unparseable datetime")),
"unexpected error: {err:?}"
);
}
#[test]
fn test_transaction_rollback_on_error() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue("bd-tx1", "Tx Test", Status::Open, 2, None, Utc::now(), None);
storage.create_issue(&issue, "tester").unwrap();
// Attempt a mutation that fails
let result: Result<()> = storage.mutate("fail_op", "tester", |_tx, ctx| {
// Do something valid first (record an event)
ctx.record_event(
EventType::Updated,
"bd-tx1",
Some("Should be rolled back".to_string()),
);
// Return error to trigger rollback
Err(BeadsError::Config("Planned failure".to_string()))
});
assert!(result.is_err());
// Verify side effects (event) are gone
let events = storage.get_events("bd-tx1", 100).unwrap();
// Should only have the creation event
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, EventType::Created);
}
#[test]
fn test_transaction_rolls_back_on_closure_error() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue(
"bd-tx-side-effect",
"Tx Side Effect Test",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
// Verify that a closure error causes a rollback.
let result: Result<()> = storage.mutate("fail_in_closure", "tester", |_tx, _ctx| {
Err(BeadsError::validation(
"test",
"intentional failure for rollback test",
))
});
assert!(
result.is_err(),
"closure error should propagate as transaction failure"
);
// Subsequent writes should still succeed after rollback.
let follow_up = make_issue(
"bd-tx-side-effect-2",
"Follow Up",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&follow_up, "tester").unwrap();
assert!(
storage.get_issue("bd-tx-side-effect-2").unwrap().is_some(),
"subsequent writes should succeed after rollback"
);
}
#[test]
fn test_event_insert_for_missing_issue_succeeds_with_fk_disabled() {
// FK enforcement is disabled during mutate transactions to work
// around fsqlite's false FK violations (#215). Events for
// non-existent issue_ids are tolerated rather than rejected.
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue(
"bd-tx-fk",
"FK Tolerance Test",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
let result: Result<()> = storage.mutate("fk_tolerance", "tester", |_tx, ctx| {
ctx.record_event(
EventType::Updated,
"bd-missing",
Some("Event for non-existent issue".to_string()),
);
Ok(())
});
assert!(
result.is_ok(),
"event insert for missing issue should succeed with FK disabled"
);
}
#[test]
fn test_external_dependency_blocks_and_propagates_to_children() {
let temp = TempDir::new().unwrap();
let external_root = temp.path().join("extproj");
let beads_dir = external_root.join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let _external_storage = SqliteStorage::open(&db_path).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 3, 0, 0, 0).unwrap();
let parent = make_issue("bd-p1", "Parent", Status::Open, 2, None, t1, None);
let child = make_issue("bd-c1", "Child", Status::Open, 2, None, t1, None);
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
// Parent (bd-p1) depends on external capability
storage
.add_dependency("bd-p1", "external:extproj:capability", "blocks", "tester")
.unwrap();
// Child (bd-c1) depends on Parent (bd-p1) via parent-child
storage
.add_dependency("bd-c1", "bd-p1", "parent-child", "tester")
.unwrap();
let mut external_db_paths = HashMap::new();
external_db_paths.insert("extproj".to_string(), db_path);
let statuses = storage
.resolve_external_dependency_statuses(&external_db_paths, true)
.unwrap();
assert_eq!(statuses.get("external:extproj:capability"), Some(&false));
let blockers = storage.external_blockers(&statuses).unwrap();
let parent_blockers = blockers.get("bd-p1").expect("parent blockers");
assert!(
parent_blockers
.iter()
.any(|b| b.starts_with("external:extproj:capability"))
);
let child_blockers = blockers.get("bd-c1").expect("child blockers");
assert!(child_blockers.iter().any(|b| b == "bd-p1:parent-blocked"));
}
#[test]
fn test_external_blockers_return_sorted_blocker_refs() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 3, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-external-blocked",
"External blocked",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_dependency(
"bd-external-blocked",
"external:zproj:zcap",
"blocks",
"tester",
)
.unwrap();
storage
.add_dependency(
"bd-external-blocked",
"external:aproj:acap",
"blocks",
"tester",
)
.unwrap();
let external_statuses = HashMap::from([
("external:zproj:zcap".to_string(), false),
("external:aproj:acap".to_string(), false),
]);
let blockers = storage.external_blockers(&external_statuses).unwrap();
let expected = vec![
"external:aproj:acap:blocked".to_string(),
"external:zproj:zcap:blocked".to_string(),
];
assert_eq!(
blockers.get("bd-external-blocked"),
Some(&expected),
"external blocker refs should not inherit dependency row order"
);
}
#[test]
fn test_add_dependency_rejects_external_parent_child_target() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
let err = storage
.add_dependency(
"bd-a1",
"external:proj:capability",
"parent-child",
"tester",
)
.unwrap_err();
assert!(matches!(err, BeadsError::Validation { field, .. } if field == "depends_on_id"));
}
#[test]
fn test_has_external_dependencies_preserves_empty_direct_and_malformed_target_semantics() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 3, 0, 0, 0).unwrap();
let issue = make_issue("bd-direct", "Direct", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
assert!(!storage.has_external_dependencies(true).unwrap());
assert!(!storage.has_external_dependencies(false).unwrap());
storage
.add_dependency("bd-direct", "external:project:related", "related", "tester")
.unwrap();
assert!(!storage.has_external_dependencies(true).unwrap());
assert!(storage.has_external_dependencies(false).unwrap());
storage
.add_dependency(
"bd-direct",
"external::malformed-project",
"blocks",
"tester",
)
.unwrap();
assert!(storage.has_external_dependencies(true).unwrap());
}
#[test]
fn test_has_external_dependencies_checks_all_external_parent_child_candidates() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let task_parent = make_issue(
"bd-task-parent",
"Task parent",
Status::Open,
2,
None,
t1,
None,
);
let mut epic_parent = make_issue(
"bd-epic-parent",
"Epic parent",
Status::Open,
2,
None,
t1,
None,
);
epic_parent.issue_type = IssueType::Epic;
storage.create_issue(&task_parent, "tester").unwrap();
storage.create_issue(&epic_parent, "tester").unwrap();
insert_external_parent_child_dependency(
&storage,
"external:aaa:task-child",
"bd-task-parent",
t1,
);
insert_external_parent_child_dependency(
&storage,
"external::malformed-epic-child",
"bd-epic-parent",
t1,
);
assert!(storage.has_external_dependencies(true).unwrap());
assert!(storage.has_external_dependencies(false).unwrap());
}
#[test]
fn test_has_external_dependencies_propagates_guard_index_errors() {
let storage = SqliteStorage::open_memory().unwrap();
storage
.conn
.execute("DROP INDEX idx_dependencies_issue")
.unwrap();
assert!(storage.has_external_dependencies(true).is_err());
}
#[test]
fn test_has_external_dependencies_detects_external_parent_child_children() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 3, 0, 0, 0).unwrap();
let mut parent = make_issue("bd-p1", "Parent", Status::Open, 2, None, t1, None);
parent.issue_type = IssueType::Epic;
storage.create_issue(&parent, "tester").unwrap();
insert_external_parent_child_dependency(&storage, "external:extproj:child", "bd-p1", t1);
assert!(storage.has_external_dependencies(true).unwrap());
assert!(storage.has_external_dependencies(false).unwrap());
assert!(storage.may_have_blocked_command_results().unwrap());
}
#[test]
fn test_missing_issue_references_allows_external_dependency_endpoints() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 3, 0, 0, 0).unwrap();
let local = make_issue("bd-local", "Local issue", Status::Open, 2, None, t1, None);
let mut epic = make_issue("bd-epic", "Epic issue", Status::Open, 2, None, t1, None);
epic.issue_type = IssueType::Epic;
storage.create_issue(&local, "tester").unwrap();
storage.create_issue(&epic, "tester").unwrap();
storage
.add_dependency("bd-local", "external:target:cap", "blocks", "tester")
.unwrap();
insert_external_parent_child_dependency(&storage, "external:child:cap", "bd-epic", t1);
assert!(
!storage
.has_missing_issue_reference("dependencies", "depends_on_id")
.unwrap(),
"external dependency targets are valid cross-project blockers"
);
assert!(
!storage
.has_missing_issue_reference("dependencies", "issue_id")
.unwrap(),
"external parent-child children are valid cross-project endpoints"
);
assert_eq!(
storage.missing_issue_references().unwrap(),
Vec::<String>::new()
);
}
#[test]
fn test_external_parent_child_task_parent_is_not_blocked_candidate() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 4, 0, 0, 0).unwrap();
let parent = make_issue(
"bd-task-parent",
"Task Parent",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&parent, "tester").unwrap();
insert_external_parent_child_dependency(
&storage,
"external:extproj:task-child",
"bd-task-parent",
t1,
);
assert!(!storage.has_external_dependencies(true).unwrap());
assert!(storage.has_external_dependencies(false).unwrap());
assert!(!storage.may_have_blocked_command_results().unwrap());
}
#[test]
fn test_blocking_only_external_resolution_skips_non_epic_external_children() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 5, 0, 0, 0).unwrap();
let direct = make_issue(
"bd-direct",
"Direct blocker",
Status::Open,
2,
None,
t1,
None,
);
let task_parent = make_issue(
"bd-task-parent",
"Task Parent",
Status::Open,
2,
None,
t1,
None,
);
let mut epic_parent = make_issue(
"bd-epic-parent",
"Epic Parent",
Status::Open,
2,
None,
t1,
None,
);
epic_parent.issue_type = IssueType::Epic;
for issue in [direct, task_parent, epic_parent] {
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_dependency(
"bd-direct",
"external:direct:capability",
"blocks",
"tester",
)
.unwrap();
insert_external_parent_child_dependency(
&storage,
"external:extproj:task-child",
"bd-task-parent",
t1,
);
insert_external_parent_child_dependency(
&storage,
"external:extproj:epic-child",
"bd-epic-parent",
t1,
);
let statuses = storage
.resolve_external_dependency_statuses(&HashMap::new(), true)
.unwrap();
assert_eq!(statuses.get("external:direct:capability"), Some(&false));
assert_eq!(statuses.get("external:extproj:epic-child"), Some(&false));
assert!(
!statuses.contains_key("external:extproj:task-child"),
"blocking-only resolution should not query external children of non-epic parents"
);
}
#[test]
fn test_external_parent_child_child_only_blocks_epic_parent() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 6, 0, 0, 0).unwrap();
let task_parent = make_issue(
"bd-task-parent",
"Task Parent",
Status::Open,
2,
None,
t1,
None,
);
let mut epic_parent = make_issue(
"bd-epic-parent",
"Epic Parent",
Status::Open,
2,
None,
t1,
None,
);
epic_parent.issue_type = IssueType::Epic;
storage.create_issue(&task_parent, "tester").unwrap();
storage.create_issue(&epic_parent, "tester").unwrap();
for (external_child, parent_id) in [
("external:extproj:task-child", "bd-task-parent"),
("external:extproj:epic-child", "bd-epic-parent"),
] {
insert_external_parent_child_dependency(&storage, external_child, parent_id, t1);
}
let external_statuses = HashMap::from([
("external:extproj:task-child".to_string(), false),
("external:extproj:epic-child".to_string(), false),
]);
let blockers = storage.external_blockers(&external_statuses).unwrap();
assert!(
!blockers.contains_key("bd-task-parent"),
"external children should not make non-epic parents blocked"
);
let epic_blockers = blockers
.get("bd-epic-parent")
.expect("external child should block epic parent");
assert_eq!(epic_blockers.len(), 1);
assert_eq!(
epic_blockers.first().map(String::as_str),
Some("external:extproj:epic-child:child-blocked")
);
}
#[test]
fn test_blocked_command_candidate_probe_empty_cache_and_no_external_deps_is_false() {
let storage = SqliteStorage::open_memory().unwrap();
assert!(!storage.may_have_blocked_command_results().unwrap());
}
#[test]
fn test_blocked_command_candidate_probe_detects_local_blocked_cache() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 4, 0, 0, 0).unwrap();
let blocker = make_issue("bd-blocker", "Blocker", Status::Open, 2, None, t1, None);
let blocked = make_issue("bd-blocked", "Blocked", Status::Open, 2, None, t1, None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency("bd-blocked", "bd-blocker", "blocks", "tester")
.unwrap();
assert!(storage.may_have_blocked_command_results().unwrap());
}
#[test]
fn test_blocked_command_candidate_probe_detects_external_blockers() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 5, 0, 0, 0).unwrap();
let issue = make_issue("bd-p1", "Parent", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_dependency("bd-p1", "external:extproj:capability", "blocks", "tester")
.unwrap();
assert!(storage.may_have_blocked_command_results().unwrap());
}
/// Regression for beads_rust#285. The issue reported that `br close`
/// persisted to JSONL but not to the SQLite store and that the
/// dirty-tracker stayed empty — meaning the JSONL→DB→JSONL
/// reconciliation never fired for the row. Pins both halves of
/// the close-as-update contract: the SQLite row reports
/// `status='closed'` post-update, and `dirty_issues` contains the
/// id so the next flush exports the change. If anyone regresses
/// `update_issue`'s `ctx.mark_dirty(id)` call (sqlite.rs:2634 at
/// the time this test was added) this fails fast.
#[test]
fn test_close_path_marks_dirty_and_persists_to_db() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-close-285", "Close me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
// Sanity: dirty-tracker is empty after create. (`create_issue`
// marks dirty, so we clear it explicitly so the assertion below
// measures only the close path's contribution.)
storage.clear_all_dirty_issues().unwrap();
assert_eq!(storage.get_dirty_issue_count().unwrap(), 0);
let close_update = IssueUpdate {
status: Some(Status::Closed),
closed_at: Some(Some(Utc.with_ymd_and_hms(2026, 5, 2, 0, 0, 0).unwrap())),
close_reason: Some(Some("done".to_string())),
skip_cache_rebuild: true,
..IssueUpdate::default()
};
let updated = storage
.update_issue("bd-close-285", &close_update, "tester")
.expect("close path must succeed");
// Half 1: the in-memory return value reports Closed.
assert_eq!(updated.status, Status::Closed, "returned issue.status");
// Half 2: the SQLite store actually reflects Closed. Reading
// through `get_issue` exercises the same query path consumers
// use; a raw SELECT is unnecessary because the user-visible
// symptom is the wrong status surfacing through that API.
let reloaded = storage
.get_issue("bd-close-285")
.expect("get_issue")
.expect("issue must still exist after close");
assert_eq!(
reloaded.status,
Status::Closed,
"SQLite row must report closed; if this fails, br close persisted to JSONL but not DB (issue #285)"
);
// Half 3: dirty_issues queued the close so the next flush can
// export it. If this count is zero the JSONL→DB reconciliation
// path has nothing to act on and divergence accumulates over
// time (the 2.4% drift rate the issue reports).
assert_eq!(
storage.get_dirty_issue_count().unwrap(),
1,
"close path must enqueue dirty_issues; without this br sync --flush-only is a no-op after close (issue #285)"
);
let dirty: Vec<(String, String)> = storage.get_dirty_issue_metadata().unwrap();
assert_eq!(dirty.len(), 1);
assert_eq!(dirty[0].0, "bd-close-285");
}
#[test]
fn test_update_issue_changes_fields() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-u1", "Update me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let updates = IssueUpdate {
title: Some("Updated title".to_string()),
description: Some(Some("New description".to_string())),
status: Some(Status::InProgress),
priority: Some(Priority::HIGH),
assignee: Some(Some("alice".to_string())),
..IssueUpdate::default()
};
let updated = storage.update_issue("bd-u1", &updates, "tester").unwrap();
assert_eq!(updated.title, "Updated title");
assert_eq!(updated.status, Status::InProgress);
assert_eq!(updated.priority, Priority::HIGH);
assert_eq!(updated.assignee.as_deref(), Some("alice"));
assert_eq!(updated.description.as_deref(), Some("New description"));
}
#[test]
fn test_update_issue_writes_source_repo_path() {
// Regression for #289: `br update --source-repo-path PATH` must
// round-trip through SQLite. Without writing to the column, the
// installed checkpoint would silently lose the value on every
// create-then-update cycle.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-srp",
"source_repo_path RT",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let updates = IssueUpdate {
source_repo: Some(Some("widget_engine".to_string())),
source_repo_path: Some(Some("/data/projects/widget_engine".to_string())),
..IssueUpdate::default()
};
let updated = storage.update_issue("bd-srp", &updates, "tester").unwrap();
assert_eq!(updated.source_repo.as_deref(), Some("widget_engine"));
assert_eq!(
updated.source_repo_path.as_deref(),
Some("/data/projects/widget_engine")
);
// Read back through a fresh fetch to prove it persisted (not just
// returned from the in-memory `Issue` the update builder mutates).
let reread = storage.get_issue("bd-srp").unwrap().unwrap();
assert_eq!(reread.source_repo.as_deref(), Some("widget_engine"));
assert_eq!(
reread.source_repo_path.as_deref(),
Some("/data/projects/widget_engine")
);
// Clear path: passing an empty string through `optional_string_field`
// sets the inner Option to None, which should write SQL NULL.
let clear = IssueUpdate {
source_repo_path: Some(None),
..IssueUpdate::default()
};
let cleared = storage.update_issue("bd-srp", &clear, "tester").unwrap();
assert!(cleared.source_repo_path.is_none());
let reread = storage.get_issue("bd-srp").unwrap().unwrap();
assert!(reread.source_repo_path.is_none());
}
#[test]
fn test_update_issue_same_priority_is_noop() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-u-priority",
"No-op priority",
Status::Open,
1,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage.clear_all_dirty_issues().unwrap();
let before = storage.get_issue("bd-u-priority").unwrap().unwrap();
let updated = storage
.update_issue(
"bd-u-priority",
&IssueUpdate {
priority: Some(Priority::HIGH),
..IssueUpdate::default()
},
"tester",
)
.unwrap();
assert_eq!(updated.priority, Priority::HIGH);
assert_eq!(updated.updated_at, before.updated_at);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_update_issue_rejects_existing_tombstone() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-u-tomb",
"Do not resurrect",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let tombstone = storage
.delete_issue("bd-u-tomb", "tester", "delete for update test", None)
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let err = storage
.update_issue(
"bd-u-tomb",
&IssueUpdate {
title: Some("Resurrected title".to_string()),
status: Some(Status::Open),
deleted_at: Some(None),
deleted_by: Some(None),
delete_reason: Some(None),
..IssueUpdate::default()
},
"tester",
)
.unwrap_err();
assert!(
matches!(
err,
BeadsError::Validation {
ref field,
ref reason
}
if field == "issue_id"
&& reason.contains("cannot update tombstone issue: bd-u-tomb")
),
"unexpected error: {err:?}"
);
let after = storage.get_issue("bd-u-tomb").unwrap().unwrap();
assert_eq!(after.status, Status::Tombstone);
assert_eq!(after.title, tombstone.title);
assert_eq!(after.deleted_at, tombstone.deleted_at);
assert_eq!(after.deleted_by, tombstone.deleted_by);
assert_eq!(after.delete_reason, tombstone.delete_reason);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
/*
#[test]
fn test_update_issue_recomputes_hash_from_fresh_transaction_state() {
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut setup = SqliteStorage::open(&db_path).unwrap();
let issue = make_issue(
"bd-race1",
"Original title",
Status::Open,
2,
None,
Utc::now(),
None,
);
setup.create_issue(&issue, "tester").unwrap();
drop(setup);
let (ready_tx, ready_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let writer_db_path = db_path.clone();
let writer = std::thread::spawn(move || {
let storage = SqliteStorage::open(&writer_db_path).unwrap();
storage.conn.execute("BEGIN IMMEDIATE").unwrap();
storage
.conn
.execute_with_params(
"UPDATE issues SET description = ?, updated_at = ? WHERE id = ?",
&[
SqliteValue::from("Thread description"),
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from("bd-race1"),
],
)
.unwrap();
ready_tx.send(()).unwrap();
release_rx.recv().unwrap();
storage.conn.execute("COMMIT").unwrap();
});
ready_rx.recv().unwrap();
let updater_db_path = db_path;
let updater = std::thread::spawn(move || {
let mut storage = SqliteStorage::open(&updater_db_path).unwrap();
let updates = IssueUpdate {
title: Some("Updated title".to_string()),
..IssueUpdate::default()
};
storage
.update_issue("bd-race1", &updates, "tester")
.unwrap();
storage.get_issue("bd-race1").unwrap().unwrap()
});
std::thread::sleep(Duration::from_millis(50));
release_tx.send(()).unwrap();
writer.join().unwrap();
let updated = updater.join().unwrap();
assert_eq!(updated.description.as_deref(), Some("Thread description"));
assert_eq!(
updated.content_hash.as_deref(),
Some(crate::util::content_hash(&updated).as_str())
);
}
*/
#[test]
fn test_delete_issue_sets_tombstone() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-d1", "Delete me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let deleted = storage
.delete_issue("bd-d1", "tester", "cleanup", None)
.unwrap();
assert_eq!(deleted.status, Status::Tombstone);
assert_eq!(deleted.delete_reason.as_deref(), Some("cleanup"));
let is_tombstone = storage.is_tombstone("bd-d1").unwrap();
assert!(is_tombstone);
}
#[test]
fn test_reopen_records_reopened_event() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-r1", "Reopen me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let close_update = IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
};
storage
.update_issue("bd-r1", &close_update, "tester")
.unwrap();
let reopen_update = IssueUpdate {
status: Some(Status::Open),
closed_at: Some(None),
close_reason: Some(None),
closed_by_session: Some(None),
..IssueUpdate::default()
};
storage
.update_issue("bd-r1", &reopen_update, "tester")
.unwrap();
let events = storage.get_events("bd-r1", 10).unwrap();
println!("Events: {:#?}", events);
assert!(
events
.iter()
.any(|event| event.event_type == EventType::Reopened)
);
}
#[test]
fn test_reopen_clears_close_metadata() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-rmeta",
"Reopen metadata",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let close_update = IssueUpdate {
status: Some(Status::Closed),
close_reason: Some(Some("done".to_string())),
..IssueUpdate::default()
};
storage
.update_issue("bd-rmeta", &close_update, "tester")
.unwrap();
storage
.record_close_metadata(
"bd-rmeta",
&crate::close_policy::AttributionValues::default(),
false,
None,
&[],
)
.unwrap();
assert!(storage.get_close_metadata("bd-rmeta").unwrap().is_some());
let reopen_update = IssueUpdate {
status: Some(Status::Open),
closed_at: Some(None),
close_reason: Some(None),
closed_by_session: Some(None),
..IssueUpdate::default()
};
storage
.update_issue("bd-rmeta", &reopen_update, "tester")
.unwrap();
assert!(storage.get_close_metadata("bd-rmeta").unwrap().is_none());
}
#[test]
fn test_delete_issue_clears_close_metadata() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-dmeta",
"Delete metadata",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let close_update = IssueUpdate {
status: Some(Status::Closed),
close_reason: Some(Some("done".to_string())),
..IssueUpdate::default()
};
storage
.update_issue("bd-dmeta", &close_update, "tester")
.unwrap();
storage
.record_close_metadata(
"bd-dmeta",
&crate::close_policy::AttributionValues::default(),
false,
None,
&[],
)
.unwrap();
assert!(storage.get_close_metadata("bd-dmeta").unwrap().is_some());
storage
.delete_issue("bd-dmeta", "tester", "cleanup", None)
.unwrap();
assert!(storage.get_close_metadata("bd-dmeta").unwrap().is_none());
}
#[test]
fn test_delete_issue_recomputes_content_hash_for_tombstone() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-d2", "Delete me too", Status::Open, 2, None, t1, None);
let original_hash = issue.content_hash.clone();
storage.create_issue(&issue, "tester").unwrap();
let deleted = storage
.delete_issue("bd-d2", "tester", "cleanup", None)
.unwrap();
assert_eq!(deleted.status, Status::Tombstone);
assert_ne!(deleted.content_hash, original_hash);
assert_eq!(
deleted.content_hash.as_deref(),
Some(crate::util::content_hash(&deleted).as_str())
);
}
#[test]
fn test_delete_issue_is_idempotent_for_tombstones() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let deleted_at = Utc.with_ymd_and_hms(2025, 6, 2, 0, 0, 0).unwrap();
let issue = make_issue("bd-d3", "Already deleted", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let first = storage
.delete_issue("bd-d3", "first", "first delete", Some(deleted_at))
.unwrap();
storage.clear_dirty_flags(&["bd-d3".to_string()]).unwrap();
let second = storage
.delete_issue("bd-d3", "second", "second delete", None)
.unwrap();
assert_eq!(second.status, Status::Tombstone);
assert_eq!(second.deleted_at, first.deleted_at);
assert_eq!(second.deleted_by, first.deleted_by);
assert_eq!(second.delete_reason, first.delete_reason);
assert_eq!(second.updated_at, first.updated_at);
assert_eq!(storage.get_dirty_issue_ids().unwrap(), Vec::<String>::new());
assert_eq!(
storage
.get_events("bd-d3", 10)
.unwrap()
.iter()
.filter(|event| event.event_type == EventType::Deleted)
.count(),
1
);
}
#[test]
fn test_purge_issue_succeeds_without_fk_side_effects() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-p1", "Purge me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage.purge_issue("bd-p1", "tester").unwrap();
assert!(storage.get_issue("bd-p1").unwrap().is_none());
let dirty_count = storage
.conn
.query_row("SELECT COUNT(*) FROM dirty_issues WHERE issue_id = 'bd-p1'")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(dirty_count, 0);
let event_count = storage
.conn
.query_row("SELECT COUNT(*) FROM events WHERE issue_id = 'bd-p1'")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(event_count, 0);
}
#[test]
fn test_purge_issue_removes_every_issue_owned_auxiliary_row() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-purge-aux",
"Purge auxiliary state",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute(
"INSERT INTO close_metadata (issue_id, bypassed_policy) \
VALUES ('bd-purge-aux', 0)",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO gate_results (issue_id, gate, provider, passed) \
VALUES ('bd-purge-aux', 'review', 'test', 1)",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO gate_result_history \
(issue_id, from_status, to_status, status_revision, gate, provider, passed) \
VALUES ('bd-purge-aux', 'open', 'closed', 1, 'review', 'test', 1)",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO capacity_exemptions \
(issue_id, capacity_kind, capacity_name, provider, reason, granted_by) \
VALUES ('bd-purge-aux', 'status', 'open', 'test', 'test', 'tester')",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO capacity_exemption_history \
(issue_id, capacity_kind, capacity_name, action, provider, actor) \
VALUES ('bd-purge-aux', 'status', 'open', 'grant', 'test', 'tester')",
)
.unwrap();
storage
.conn
.execute(
"INSERT OR REPLACE INTO capacity_occupancy (issue_id, actor) \
VALUES ('bd-purge-aux', 'tester')",
)
.unwrap();
storage.purge_issue("bd-purge-aux", "tester").unwrap();
for table in [
"close_metadata",
"gate_results",
"gate_result_history",
"capacity_exemptions",
"capacity_exemption_history",
"capacity_occupancy",
] {
let count = storage
.conn
.query_row(&format!(
"SELECT COUNT(*) FROM {table} WHERE issue_id = 'bd-purge-aux'"
))
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or_default();
assert_eq!(count, 0, "purge left an issue-owned row in {table}");
}
let foreign_key_violations = storage.conn.query("PRAGMA foreign_key_check").unwrap();
assert!(
foreign_key_violations.is_empty(),
"purge left foreign-key violations: {foreign_key_violations:?}"
);
}
/// GitHub #471: the auxiliary/history snapshot must carry every DB-only
/// table across a rebuild, keep ids when the target tables are empty,
/// and skip rows whose issue did not survive the rebuild.
#[test]
fn test_auxiliary_history_snapshot_restores_after_rebuild() {
let t1 = Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
let mut source = SqliteStorage::open_memory().unwrap();
let survivor = make_issue("bd-hist-keep", "survives", Status::Open, 2, None, t1, None);
let dropped = make_issue("bd-hist-drop", "dropped", Status::Open, 2, None, t1, None);
source.create_issue(&survivor, "tester").unwrap();
source.create_issue(&dropped, "tester").unwrap();
for (issue, gate) in [("bd-hist-keep", "review"), ("bd-hist-drop", "review")] {
source
.conn
.execute_with_params(
"INSERT INTO gate_result_history \
(issue_id, from_status, to_status, status_revision, gate, provider, passed) \
VALUES (?, 'open', 'closed', 1, ?, 'test', 1)",
&[SqliteValue::from(issue), SqliteValue::from(gate)],
)
.unwrap();
}
source
.conn
.execute(
"INSERT INTO close_metadata (issue_id, bypassed_policy, bypass_reason) \
VALUES ('bd-hist-keep', 1, 'urgent')",
)
.unwrap();
source
.conn
.execute(
"INSERT OR REPLACE INTO capacity_occupancy (issue_id, actor) \
VALUES ('bd-hist-keep', 'tester')",
)
.unwrap();
let source_events = source.get_events("bd-hist-keep", 0).unwrap().len();
assert!(source_events > 0, "creation must have produced events");
let snapshot = source.snapshot_auxiliary_history_tables();
assert!(snapshot.failures.is_empty(), "{:?}", snapshot.failures);
assert!(snapshot.row_count() > 0);
// Rebuild target holds only the survivor (as after a JSONL rebuild
// whose export lacked the dropped issue).
let mut rebuilt = SqliteStorage::open_memory().unwrap();
rebuilt.create_issue(&survivor, "import").unwrap();
let report = rebuilt.restore_auxiliary_history_tables(&snapshot).unwrap();
let restored_events = rebuilt
.conn
.query_row("SELECT COUNT(*) FROM events WHERE issue_id = 'bd-hist-keep'")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap();
assert!(
restored_events >= i64::try_from(source_events).unwrap(),
"survivor events must be preserved (got {restored_events})"
);
let gate_rows = rebuilt
.conn
.query("SELECT issue_id FROM gate_result_history")
.unwrap();
assert_eq!(gate_rows.len(), 1, "only the survivor's gate history stays");
assert_eq!(
gate_rows[0].get(0).and_then(SqliteValue::as_text),
Some("bd-hist-keep")
);
let close_reason = rebuilt
.conn
.query_row("SELECT bypass_reason FROM close_metadata WHERE issue_id = 'bd-hist-keep'")
.unwrap()
.get(0)
.and_then(SqliteValue::as_text)
.map(String::from);
assert_eq!(close_reason.as_deref(), Some("urgent"));
let occupancy = rebuilt
.conn
.query_row("SELECT COUNT(*) FROM capacity_occupancy")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap();
assert_eq!(occupancy, 1);
assert!(
report
.iter()
.any(|(table, restored, _)| table == "gate_result_history" && *restored == 1),
"{report:?}"
);
assert!(
report
.iter()
.any(|(table, _, skipped)| table == "gate_result_history" && *skipped == 1),
"dropped issue's history must be counted as skipped: {report:?}"
);
let fk = rebuilt.conn.query("PRAGMA foreign_key_check").unwrap();
assert!(fk.is_empty(), "restore left FK violations: {fk:?}");
}
/// GitHub #474: a bypassed close must travel through the JSONL export
/// and re-import into `close_metadata` on another machine.
#[test]
fn test_close_bypass_audit_exports_and_imports() {
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let mut issue = make_issue(
"bd-bypassed",
"bypassed close",
Status::Closed,
2,
None,
t1,
None,
);
issue.closed_at = Some(t1);
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute(
"INSERT INTO close_metadata \
(issue_id, bypassed_policy, bypass_reason, policy_gates_fired) \
VALUES ('bd-bypassed', 1, 'demonstrating the export gap', \
'[\"typed_references_required\"]')",
)
.unwrap();
let exported = storage
.get_issues_for_export(&["bd-bypassed".to_string()])
.unwrap();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].bypassed_policy, Some(true));
assert_eq!(
exported[0].bypass_reason.as_deref(),
Some("demonstrating the export gap")
);
assert_eq!(
exported[0].policy_gates_fired.as_deref(),
Some(&["typed_references_required".to_string()][..])
);
let line = serde_json::to_string(&exported[0]).unwrap();
assert!(line.contains("\"bypassed_policy\":true"), "{line}");
assert!(line.contains("bypass_reason"), "{line}");
// A non-bypassed close serializes no audit fields (additive schema).
let mut clean = make_issue("bd-clean", "clean close", Status::Closed, 2, None, t1, None);
clean.closed_at = Some(t1);
storage.create_issue(&clean, "tester").unwrap();
let clean_export = storage
.get_issues_for_export(&["bd-clean".to_string()])
.unwrap();
let clean_line = serde_json::to_string(&clean_export[0]).unwrap();
assert!(!clean_line.contains("bypassed_policy"), "{clean_line}");
// Import the exported record on a "different machine".
let other = SqliteStorage::open_memory().unwrap();
let parsed: Issue = serde_json::from_str(&line).unwrap();
other.upsert_issue_for_import(&parsed).unwrap();
let row = other
.conn
.query_row(
"SELECT bypassed_policy, bypass_reason, policy_gates_fired \
FROM close_metadata WHERE issue_id = 'bd-bypassed'",
)
.unwrap();
assert_eq!(row.get(0).and_then(SqliteValue::as_integer), Some(1));
assert_eq!(
row.get(1).and_then(SqliteValue::as_text),
Some("demonstrating the export gap")
);
assert_eq!(
row.get(2).and_then(SqliteValue::as_text),
Some("[\"typed_references_required\"]")
);
}
#[test]
fn test_get_blocked_issues_lists_blockers() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, t1, None);
let blocked = make_issue("bd-b2", "Blocked", Status::Open, 2, None, t1, None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency("bd-b2", "bd-b1", "blocks", "tester")
.unwrap();
let blocked_issues = storage.get_blocked_issues().unwrap();
assert_eq!(blocked_issues.len(), 1);
assert_eq!(blocked_issues[0].0.id, "bd-b2");
assert_eq!(blocked_issues[0].1.len(), 1);
}
#[test]
fn test_count_all_relation_counts_matches_chunked_counts() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
for id in ["bd-a", "bd-b", "bd-c"] {
let issue = make_issue(id, id, Status::Open, 1, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_dependency("bd-b", "bd-a", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-c", "bd-a", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-c", "bd-b", "blocks", "tester")
.unwrap();
let ids = vec!["bd-a".to_string(), "bd-b".to_string(), "bd-c".to_string()];
let chunked_counts = storage.count_relation_counts_for_issues(&ids).unwrap();
let all_counts = storage.count_all_relation_counts().unwrap();
assert_eq!(all_counts, chunked_counts);
}
#[test]
fn test_scheduler_evidence_helpers_handle_default_candidate_window() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
let ids = (0..512)
.map(|index| format!("bd-window-{index:03}"))
.collect::<Vec<_>>();
for id in &ids {
let issue = make_issue(id, id, Status::Open, 1, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage.add_label(id, "scheduler", "tester").unwrap();
}
for issue_id in ids.iter().skip(1) {
storage
.add_dependency(issue_id, &ids[0], "blocks", "tester")
.unwrap();
}
let labels = storage.get_labels_for_issues(&ids).unwrap();
let (dependency_counts, dependent_counts) =
storage.count_relation_counts_for_issues(&ids).unwrap();
assert_eq!(labels.len(), ids.len());
assert!(ids.iter().all(|id| labels[id] == ["scheduler"]));
assert_eq!(*dependent_counts.get(&ids[0]).unwrap_or(&0), ids.len() - 1);
for issue_id in ids.iter().skip(1) {
assert_eq!(*dependency_counts.get(issue_id).unwrap_or(&0), 1);
}
}
/// Three issues, two labels: only `bd-both` carries both.
/// The ordinary open trusts the recorded schema witness instead of
/// walking every table, and DDL behind br's back (which moves SQLite's
/// schema cookie) still forces the full check, which heals the schema.
#[test]
fn ordinary_open_trusts_recorded_witness_and_revalidates_after_ddl() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("beads.db");
let storage = SqliteStorage::open(&db_path).unwrap();
assert!(
crate::storage::schema::runtime_schema_witness_matches(&storage.conn),
"a healthy open must leave a witness for its exact schema cookie"
);
drop(storage);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("DROP INDEX idx_issues_status").unwrap();
assert!(
!crate::storage::schema::runtime_schema_witness_matches(&conn),
"DDL moves the schema cookie, so the stale witness must stop matching"
);
conn.close().unwrap();
let storage = SqliteStorage::open(&db_path).unwrap();
let healed = storage
.conn
.query(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_issues_status'",
)
.unwrap();
assert_eq!(
healed.len(),
1,
"the full compatibility check must recreate the dropped index"
);
assert!(
crate::storage::schema::runtime_schema_witness_matches(&storage.conn),
"the healed schema must be attested and witnessed again"
);
}
fn storage_with_multi_label_fixture() -> SqliteStorage {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
for id in ["bd-both", "bd-one", "bd-other"] {
let issue = make_issue(id, id, Status::Open, 1, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage.add_label("bd-both", "backend", "tester").unwrap();
storage.add_label("bd-both", "urgent", "tester").unwrap();
storage.add_label("bd-one", "backend", "tester").unwrap();
storage.add_label("bd-other", "urgent", "tester").unwrap();
storage
}
const GROUPED_HAVING_IN_SUBQUERY_COUNT: &str =
"SELECT COUNT(*) FROM issues WHERE issues.id IN (
SELECT issue_id FROM labels
WHERE label IN (?, ?)
GROUP BY issue_id
HAVING COUNT(DISTINCT label) = ?
)";
fn first_integer(rows: &[crate::franken_sync::Row]) -> Option<i64> {
rows.first()
.and_then(|row| row.get(0))
.and_then(SqliteValue::as_integer)
}
/// beads_rust-ro3m: the public multi-label AND count must agree with the
/// list, whichever SQL path the engine forces it through. The literal
/// form of the grouped/HAVING IN-subquery is asserted too, to pin down
/// that the engine defect is specific to bound parameters.
#[test]
fn multi_label_and_count_matches_list() {
let storage = storage_with_multi_label_fixture();
let literal = storage
.conn
.query(
"SELECT COUNT(*) FROM issues WHERE issues.id IN (
SELECT issue_id FROM labels
WHERE label IN ('backend', 'urgent')
GROUP BY issue_id
HAVING COUNT(DISTINCT label) = 2
)",
)
.unwrap();
assert_eq!(
first_integer(&literal),
Some(1),
"literal form: {literal:?}"
);
let filters = ListFilters {
labels: Some(vec!["backend".to_string(), "urgent".to_string()]),
..Default::default()
};
assert_eq!(storage.count_issues_with_filters(&filters).unwrap(), 1);
let listed = storage.list_issues(&filters).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "bd-both");
}
/// The statement `count_issues_with_filters` runs on its uncorrelated-IN
/// fast path for a default multi-label AND filter (`WHERE 1=1`, the
/// default-visibility status clause, the template clause).
const PRODUCTION_SHAPED_COUNT: &str = "SELECT COUNT(*) FROM issues WHERE 1=1 AND issues.id IN (
SELECT issue_id
FROM labels
WHERE label IN (?,?)
GROUP BY issue_id
HAVING COUNT(DISTINCT label) = ?
) AND status NOT IN ('closed', 'tombstone', 'deferred') \
AND (is_template = 0 OR is_template IS NULL)";
/// beads_rust-ro3m engine probe: every parametrized way of asking the
/// engine the multi-label AND count, so the failing variant is named.
/// On fsqlite 0.3.15 and 0.3.16 the two production-shaped variants return
/// NULL (`Ok(None)`) while the minimal statement counts 1 through both
/// query APIs: the grouped/HAVING IN-subquery breaks once further
/// predicates follow it. The original probe passes on fsqlite 0.3.18;
/// keep all four variants in the normal suite now that the public count
/// uses the grouped subquery directly again.
#[test]
fn grouped_having_in_subquery_count_with_bound_params() {
let storage = storage_with_multi_label_fixture();
let params = [
SqliteValue::from("backend"),
SqliteValue::from("urgent"),
SqliteValue::from(2_i64),
];
let query_rows = |sql: &str| {
storage
.conn
.query_with_params(sql, ¶ms)
.map(|rows| first_integer(&rows))
.map_err(|err| err.to_string())
};
let query_row = |sql: &str| {
storage
.conn
.query_row_with_params(sql, ¶ms)
.map(|row| row.get(0).and_then(SqliteValue::as_integer))
.map_err(|err| err.to_string())
};
let variants = [
(
"minimal / query_with_params",
query_rows(GROUPED_HAVING_IN_SUBQUERY_COUNT),
),
(
"minimal / query_row_with_params",
query_row(GROUPED_HAVING_IN_SUBQUERY_COUNT),
),
(
"production-shaped / query_with_params",
query_rows(PRODUCTION_SHAPED_COUNT),
),
(
"production-shaped / query_row_with_params",
query_row(PRODUCTION_SHAPED_COUNT),
),
];
let wrong: Vec<&(&str, std::result::Result<Option<i64>, String>)> = variants
.iter()
.filter(|(_, count)| !matches!(count, Ok(Some(1))))
.collect();
assert!(
wrong.is_empty(),
"fsqlite {}: these variants did not count 1: {wrong:?}",
option_env!("BR_FSQLITE_VERSION").unwrap_or("unknown")
);
}
#[test]
fn test_all_list_relation_metadata_matches_separate_helpers() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
for id in ["bd-a", "bd-b", "bd-c"] {
let issue = make_issue(id, id, Status::Open, 1, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage.add_label("bd-a", "backend", "tester").unwrap();
storage.add_label("bd-a", "urgent", "tester").unwrap();
storage.add_label("bd-b", "backend", "tester").unwrap();
storage
.add_dependency("bd-b", "bd-a", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-c", "bd-a", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-c", "bd-b", "blocks", "tester")
.unwrap();
let labels = storage.get_all_labels().unwrap();
let (dependency_counts, dependent_counts) = storage.count_all_relation_counts().unwrap();
let metadata = storage.get_all_list_relation_metadata().unwrap();
for id in ["bd-a", "bd-b", "bd-c"] {
let entry = metadata.get(id);
assert_eq!(
entry
.map(|metadata| metadata.labels.as_slice())
.unwrap_or(&[]),
labels.get(id).map(Vec::as_slice).unwrap_or(&[])
);
assert_eq!(
entry.map_or(0, |metadata| metadata.dependency_count),
*dependency_counts.get(id).unwrap_or(&0)
);
assert_eq!(
entry.map_or(0, |metadata| metadata.dependent_count),
*dependent_counts.get(id).unwrap_or(&0)
);
}
}
#[test]
fn test_get_blocked_issues_includes_nonterminal_statuses() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, t1, None);
let mut deferred = make_issue(
"bd-b2",
"Deferred blocked",
Status::Deferred,
2,
None,
t1,
None,
);
deferred.defer_until = Some(t1 + chrono::Duration::days(1));
let custom = make_issue(
"bd-b3",
"Custom blocked",
Status::Custom("review".to_string()),
2,
None,
t1,
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&deferred, "tester").unwrap();
storage.create_issue(&custom, "tester").unwrap();
storage
.add_dependency("bd-b2", "bd-b1", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-b3", "bd-b1", "blocks", "tester")
.unwrap();
let blocked_issues = storage.get_blocked_issues().unwrap();
let ids: HashSet<_> = blocked_issues
.iter()
.map(|(issue, _)| issue.id.as_str())
.collect();
assert!(ids.contains("bd-b2"));
assert!(ids.contains("bd-b3"));
}
#[test]
fn test_add_and_remove_labels_sorted() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-l1", "Label me", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let added = storage.add_label("bd-l1", "backend", "tester").unwrap();
assert!(added);
let added = storage.add_label("bd-l1", "api", "tester").unwrap();
assert!(added);
let labels = storage.get_labels("bd-l1").unwrap();
assert_eq!(labels, vec!["api".to_string(), "backend".to_string()]);
let removed = storage.remove_label("bd-l1", "api", "tester").unwrap();
assert!(removed);
let labels = storage.get_labels("bd-l1").unwrap();
assert_eq!(labels, vec!["backend".to_string()]);
}
#[test]
fn test_add_label_rejects_invalid_storage_label() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-invalid",
"Invalid label",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let err = storage
.add_label("bd-l-invalid", "bad label", "tester")
.expect_err("invalid labels must be rejected at storage boundary");
assert!(err.to_string().contains("invalid characters"));
assert!(storage.get_labels("bd-l-invalid").unwrap().is_empty());
}
#[test]
fn test_remove_label_rejects_invalid_storage_label_without_mutating() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-remove-invalid",
"Invalid label removal",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_label("bd-l-remove-invalid", "backend", "tester")
.unwrap();
let err = storage
.remove_label("bd-l-remove-invalid", "bad label", "tester")
.expect_err("invalid removal label must be rejected at storage boundary");
assert!(err.to_string().contains("invalid characters"));
assert_eq!(
storage.get_labels("bd-l-remove-invalid").unwrap(),
vec!["backend".to_string()]
);
}
#[test]
fn test_add_label_enforces_issue_label_limit() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-l-cap", "Label cap", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
for index in 0..ISSUE_LABEL_MAX_COUNT {
let label = format!("label-{index:02}");
assert!(storage.add_label("bd-l-cap", &label, "tester").unwrap());
}
assert!(
!storage.add_label("bd-l-cap", "label-00", "tester").unwrap(),
"duplicate labels should remain an idempotent no-op at the cap"
);
let err = storage
.add_label("bd-l-cap", "label-extra", "tester")
.expect_err("new label beyond cap must fail");
assert!(err.to_string().contains("exceeds 64 labels"));
assert_eq!(
storage.get_labels("bd-l-cap").unwrap().len(),
ISSUE_LABEL_MAX_COUNT
);
}
#[test]
fn test_add_label_to_issues_bulk_marks_only_changed_ids() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
for id in ["bd-bulk-a", "bd-bulk-b", "bd-bulk-c"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_label("bd-bulk-b", "bulk-added", "tester")
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let ids = vec![
"bd-bulk-a".to_string(),
"bd-bulk-b".to_string(),
"bd-bulk-c".to_string(),
"bd-bulk-a".to_string(),
];
let changed = storage
.add_label_to_issues_bulk(&ids, "bulk-added", "tester")
.unwrap();
let expected = ["bd-bulk-a", "bd-bulk-c"]
.into_iter()
.map(String::from)
.collect::<HashSet<_>>();
assert_eq!(changed, expected);
for id in ["bd-bulk-a", "bd-bulk-b", "bd-bulk-c"] {
assert!(
storage
.get_labels(id)
.unwrap()
.contains(&"bulk-added".to_string()),
"{id} should have the bulk-added label"
);
}
let mut dirty = storage.get_dirty_issue_ids().unwrap();
dirty.sort();
assert_eq!(
dirty,
vec!["bd-bulk-a".to_string(), "bd-bulk-c".to_string()]
);
}
#[test]
fn test_add_label_to_issues_bulk_handles_sqlite_var_limit_boundary() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let ids = (0..SQLITE_VAR_LIMIT)
.map(|index| format!("bd-bulk-boundary-{index:03}"))
.collect::<Vec<_>>();
for id in &ids {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage.clear_all_dirty_issues().unwrap();
let changed = storage
.add_label_to_issues_bulk(&ids, "bulk-boundary", "tester")
.unwrap();
let expected = ids.iter().cloned().collect::<HashSet<_>>();
assert_eq!(changed, expected);
let mut dirty = storage.get_dirty_issue_ids().unwrap();
dirty.sort();
assert_eq!(dirty, ids);
}
#[test]
fn test_add_label_to_issues_bulk_rejects_cap_without_partial_mutation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
for id in ["bd-bulk-cap-ok", "bd-bulk-cap-full"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
for index in 0..ISSUE_LABEL_MAX_COUNT {
let label = format!("label-{index:02}");
storage
.add_label("bd-bulk-cap-full", &label, "tester")
.unwrap();
}
storage.clear_all_dirty_issues().unwrap();
let ids = vec!["bd-bulk-cap-ok".to_string(), "bd-bulk-cap-full".to_string()];
let err = storage
.add_label_to_issues_bulk(&ids, "label-extra", "tester")
.expect_err("bulk label add must reject a target at the label cap");
assert!(err.to_string().contains("exceeds 64 labels"));
assert!(
!storage
.get_labels("bd-bulk-cap-ok")
.unwrap()
.contains(&"label-extra".to_string()),
"bulk validation should avoid partially labeling earlier targets"
);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_add_label_to_issues_bulk_rejects_tombstone_without_partial_mutation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
for id in ["bd-bulk-active", "bd-bulk-tomb"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.delete_issue("bd-bulk-tomb", "tester", "delete target", None)
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let ids = vec!["bd-bulk-active".to_string(), "bd-bulk-tomb".to_string()];
let err = storage
.add_label_to_issues_bulk(&ids, "bulk-added", "tester")
.expect_err("bulk label add must reject tombstones");
assert!(
err.to_string()
.contains("cannot add label to tombstone issue: bd-bulk-tomb")
);
assert!(storage.get_labels("bd-bulk-active").unwrap().is_empty());
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_remove_label_from_issues_bulk_marks_only_changed_ids() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
for id in ["bd-bulk-remove-a", "bd-bulk-remove-b", "bd-bulk-remove-c"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_label("bd-bulk-remove-a", "bulk-removed", "tester")
.unwrap();
storage
.add_label("bd-bulk-remove-c", "bulk-removed", "tester")
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let ids = vec![
"bd-bulk-remove-a".to_string(),
"bd-bulk-remove-b".to_string(),
"bd-bulk-remove-c".to_string(),
"bd-bulk-remove-a".to_string(),
];
let changed = storage
.remove_label_from_issues_bulk(&ids, "bulk-removed", "tester")
.unwrap();
let expected = ["bd-bulk-remove-a", "bd-bulk-remove-c"]
.into_iter()
.map(String::from)
.collect::<HashSet<_>>();
assert_eq!(changed, expected);
for id in ["bd-bulk-remove-a", "bd-bulk-remove-b", "bd-bulk-remove-c"] {
assert!(
!storage
.get_labels(id)
.unwrap()
.contains(&"bulk-removed".to_string()),
"{id} should not have the bulk-removed label"
);
}
let mut dirty = storage.get_dirty_issue_ids().unwrap();
dirty.sort();
assert_eq!(
dirty,
vec![
"bd-bulk-remove-a".to_string(),
"bd-bulk-remove-c".to_string()
]
);
}
#[test]
fn test_remove_label_from_issues_bulk_handles_sqlite_var_limit_boundary() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let ids = (0..SQLITE_VAR_LIMIT)
.map(|index| format!("bd-bulk-remove-boundary-{index:03}"))
.collect::<Vec<_>>();
for id in &ids {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
let added = storage
.add_label_to_issues_bulk(&ids, "bulk-remove-boundary", "tester")
.unwrap();
assert_eq!(added, ids.iter().cloned().collect::<HashSet<_>>());
storage.clear_all_dirty_issues().unwrap();
let changed = storage
.remove_label_from_issues_bulk(&ids, "bulk-remove-boundary", "tester")
.unwrap();
let expected = ids.iter().cloned().collect::<HashSet<_>>();
assert_eq!(changed, expected);
let mut dirty = storage.get_dirty_issue_ids().unwrap();
dirty.sort();
assert_eq!(dirty, ids);
}
#[test]
fn test_remove_label_from_issues_bulk_rejects_tombstone_without_partial_mutation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
for id in ["bd-bulk-remove-active", "bd-bulk-remove-tomb"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_label("bd-bulk-remove-active", "bulk-removed", "tester")
.unwrap();
storage
.delete_issue("bd-bulk-remove-tomb", "tester", "delete target", None)
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let ids = vec![
"bd-bulk-remove-active".to_string(),
"bd-bulk-remove-tomb".to_string(),
];
let err = storage
.remove_label_from_issues_bulk(&ids, "bulk-removed", "tester")
.expect_err("bulk label remove must reject tombstones");
assert!(
err.to_string()
.contains("cannot remove label from tombstone issue: bd-bulk-remove-tomb")
);
assert_eq!(
storage.get_labels("bd-bulk-remove-active").unwrap(),
vec!["bulk-removed".to_string()]
);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_set_labels_deduplicates_input() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-l2", "Dedup labels", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage
.set_labels(
"bd-l2",
&[
"backend".to_string(),
"backend".to_string(),
"api".to_string(),
],
"tester",
)
.unwrap();
let labels = storage.get_labels("bd-l2").unwrap();
assert_eq!(labels, vec!["api".to_string(), "backend".to_string()]);
}
#[test]
fn test_set_labels_validates_before_replacing_existing_labels() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-set-invalid",
"Set labels",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.set_labels("bd-l-set-invalid", &["stable".to_string()], "tester")
.unwrap();
let err = storage
.set_labels("bd-l-set-invalid", &["bad label".to_string()], "tester")
.expect_err("invalid replacement label must fail before deleting old labels");
assert!(err.to_string().contains("invalid characters"));
assert_eq!(
storage.get_labels("bd-l-set-invalid").unwrap(),
vec!["stable".to_string()]
);
let too_many = (0..=ISSUE_LABEL_MAX_COUNT)
.map(|index| format!("label-{index:02}"))
.collect::<Vec<_>>();
let err = storage
.set_labels("bd-l-set-invalid", &too_many, "tester")
.expect_err("too many replacement labels must fail");
assert!(err.to_string().contains("exceeds 64 labels"));
assert_eq!(
storage.get_labels("bd-l-set-invalid").unwrap(),
vec!["stable".to_string()]
);
}
#[test]
fn test_sync_labels_for_import_validates_before_replacing_existing_labels() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-import-invalid",
"Import labels",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_label("bd-l-import-invalid", "stable", "tester")
.unwrap();
let err = storage
.sync_labels_for_import("bd-l-import-invalid", &["bad label".to_string()])
.expect_err("invalid import labels must fail before deleting old labels");
assert!(err.to_string().contains("invalid characters"));
assert_eq!(
storage.get_labels("bd-l-import-invalid").unwrap(),
vec!["stable".to_string()]
);
let too_many = (0..=ISSUE_LABEL_MAX_COUNT)
.map(|index| format!("label-{index:02}"))
.collect::<Vec<_>>();
let err = storage
.sync_labels_for_import("bd-l-import-invalid", &too_many)
.expect_err("too many import labels must fail before deleting old labels");
assert!(err.to_string().contains("exceeds 64 labels"));
assert_eq!(
storage.get_labels("bd-l-import-invalid").unwrap(),
vec!["stable".to_string()]
);
storage
.sync_labels_for_import(
"bd-l-import-invalid",
&[
"backend".to_string(),
"backend".to_string(),
"api".to_string(),
],
)
.unwrap();
assert_eq!(
storage.get_labels("bd-l-import-invalid").unwrap(),
vec!["api".to_string(), "backend".to_string()]
);
}
fn setup_dependency_import_validation_storage() -> (SqliteStorage, Issue, Issue, DateTime<Utc>)
{
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-d-import-invalid",
"Import dependencies",
Status::Open,
2,
None,
t1,
None,
);
let stable_parent = make_issue(
"bd-d-import-stable",
"Stable parent",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage.create_issue(&stable_parent, "tester").unwrap();
storage
.add_dependency(&issue.id, &stable_parent.id, "blocks", "tester")
.unwrap();
(storage, issue, stable_parent, t1)
}
fn assert_stable_dependency_unchanged(
storage: &SqliteStorage,
issue_id: &str,
stable_parent_id: &str,
) {
let dependencies = storage.get_dependencies_full(issue_id).unwrap();
assert_eq!(dependencies.len(), 1);
assert_eq!(dependencies[0].depends_on_id, stable_parent_id);
assert_eq!(
dependencies[0].dep_type,
crate::model::DependencyType::Blocks
);
}
#[test]
fn test_sync_dependencies_for_import_rejects_self_and_wrong_source_before_replacing() {
let (storage, issue, stable_parent, t1) = setup_dependency_import_validation_storage();
let self_dependency = crate::model::Dependency {
issue_id: issue.id.clone(),
depends_on_id: issue.id.clone(),
dep_type: crate::model::DependencyType::Blocks,
created_at: t1,
created_by: Some("import".to_string()),
metadata: None,
thread_id: None,
};
let err = storage
.sync_dependencies_for_import(&issue.id, &[self_dependency])
.expect_err("self-dependency must fail before deleting old dependencies");
assert!(matches!(err, BeadsError::SelfDependency { id } if id == issue.id));
assert_stable_dependency_unchanged(&storage, &issue.id, &stable_parent.id);
let wrong_source = crate::model::Dependency {
issue_id: "bd-d-import-other".to_string(),
depends_on_id: stable_parent.id.clone(),
dep_type: crate::model::DependencyType::Blocks,
created_at: t1,
created_by: Some("import".to_string()),
metadata: None,
thread_id: None,
};
let err = storage
.sync_dependencies_for_import(&issue.id, &[wrong_source])
.expect_err("wrong dependency issue_id must fail before deleting old dependencies");
assert!(
err.to_string().contains("dependency.issue_id"),
"unexpected dependency owner validation error: {err:?}"
);
assert_stable_dependency_unchanged(&storage, &issue.id, &stable_parent.id);
}
#[test]
fn test_sync_dependencies_for_import_rejects_metadata_and_parent_before_replacing() {
let (storage, issue, stable_parent, t1) = setup_dependency_import_validation_storage();
let invalid_metadata = crate::model::Dependency {
issue_id: issue.id.clone(),
depends_on_id: stable_parent.id.clone(),
dep_type: crate::model::DependencyType::Blocks,
created_at: t1,
created_by: Some("import".to_string()),
metadata: Some("{not-json".to_string()),
thread_id: None,
};
let err = storage
.sync_dependencies_for_import(&issue.id, &[invalid_metadata])
.expect_err("invalid dependency metadata must fail before deleting old dependencies");
assert!(
matches!(&err, BeadsError::Validation { field, .. } if field == "dependencies[0].metadata"),
"metadata validation error must name the offending dependency field: {err:?}"
);
// #323: the error must be actionable — naming the issue and target.
let msg = err.to_string();
assert!(
msg.contains(&issue.id) && msg.contains(&stable_parent.id),
"metadata error must name issue and target: {msg}"
);
assert_stable_dependency_unchanged(&storage, &issue.id, &stable_parent.id);
let invalid_parent = crate::model::Dependency {
issue_id: issue.id.clone(),
depends_on_id: "external:parent".to_string(),
dep_type: crate::model::DependencyType::ParentChild,
created_at: t1,
created_by: Some("import".to_string()),
metadata: None,
thread_id: None,
};
let err = storage
.sync_dependencies_for_import(&issue.id, &[invalid_parent])
.expect_err("invalid import dependency must fail before deleting old dependencies");
assert!(err.to_string().contains("parent-child dependencies"));
assert_stable_dependency_unchanged(&storage, &issue.id, &stable_parent.id);
}
#[test]
fn test_bulk_label_mutations_reject_tombstones() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-tomb",
"Deleted label target",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_label("bd-l-tomb", "existing", "tester")
.unwrap();
storage
.delete_issue("bd-l-tomb", "tester", "delete label target", None)
.unwrap();
let set_error = storage
.set_labels("bd-l-tomb", &["new".to_string()], "tester")
.unwrap_err();
assert!(
matches!(
&set_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains("cannot set labels on tombstone issue: bd-l-tomb")
),
"unexpected set_labels error: {set_error:?}"
);
let remove_error = storage
.remove_all_labels("bd-l-tomb", "tester")
.unwrap_err();
assert!(
matches!(
&remove_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains("cannot remove labels from tombstone issue: bd-l-tomb")
),
"unexpected remove_all_labels error: {remove_error:?}"
);
let labels = storage.get_labels("bd-l-tomb").unwrap();
assert_eq!(labels, vec!["existing".to_string()]);
}
#[test]
fn test_unique_label_counts_exclude_tombstone_issues() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let active = make_issue(
"bd-l-active",
"Active label count target",
Status::Open,
2,
None,
t1,
None,
);
let tombstone = make_issue(
"bd-l-deleted",
"Deleted label count target",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&active, "tester").unwrap();
storage.create_issue(&tombstone, "tester").unwrap();
storage
.add_label("bd-l-active", "shared", "tester")
.unwrap();
storage
.add_label("bd-l-active", "active-only", "tester")
.unwrap();
storage
.add_label("bd-l-deleted", "shared", "tester")
.unwrap();
storage
.add_label("bd-l-deleted", "deleted-only", "tester")
.unwrap();
storage
.delete_issue("bd-l-deleted", "tester", "delete label count target", None)
.unwrap();
assert_eq!(
storage.get_unique_labels_with_counts().unwrap(),
vec![("active-only".to_string(), 1), ("shared".to_string(), 1),]
);
}
#[test]
fn test_rename_label_same_name_is_noop() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-l3", "Rename label", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage.add_label("bd-l3", "backend", "tester").unwrap();
let event_count_before = storage.get_events("bd-l3", 100).unwrap().len();
let affected = storage
.rename_label("backend", "backend", "tester")
.unwrap();
assert_eq!(affected, 0);
assert_eq!(
storage.get_labels("bd-l3").unwrap(),
vec!["backend".to_string()]
);
assert_eq!(
storage.get_events("bd-l3", 100).unwrap().len(),
event_count_before
);
}
#[test]
fn test_rename_label_rejects_invalid_label_names_without_mutating() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-l-rename-invalid",
"Rename invalid label",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage
.add_label("bd-l-rename-invalid", "backend", "tester")
.unwrap();
let new_name_error = storage
.rename_label("backend", "bad label", "tester")
.expect_err("invalid replacement label must fail before mutation");
assert!(new_name_error.to_string().contains("invalid characters"));
assert_eq!(
storage.get_labels("bd-l-rename-invalid").unwrap(),
vec!["backend".to_string()]
);
let old_name_error = storage
.rename_label("bad label", "frontend", "tester")
.expect_err("invalid source label must fail before mutation");
assert!(old_name_error.to_string().contains("invalid characters"));
assert_eq!(
storage.get_labels("bd-l-rename-invalid").unwrap(),
vec!["backend".to_string()]
);
let same_name_error = storage
.rename_label("bad label", "bad label", "tester")
.expect_err("invalid same-name rename must not be treated as a no-op");
assert!(same_name_error.to_string().contains("invalid characters"));
}
#[test]
fn test_rename_label_skips_tombstone_issues() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
let active = make_issue(
"bd-l-active",
"Active label target",
Status::Open,
2,
None,
t1,
None,
);
let tombstone = make_issue(
"bd-l-deleted",
"Deleted label target",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&active, "tester").unwrap();
storage.create_issue(&tombstone, "tester").unwrap();
storage
.add_label("bd-l-active", "legacy", "tester")
.unwrap();
storage
.add_label("bd-l-deleted", "legacy", "tester")
.unwrap();
storage
.delete_issue("bd-l-deleted", "tester", "delete label target", None)
.unwrap();
let affected = storage.rename_label("legacy", "renamed", "tester").unwrap();
assert_eq!(affected, 1);
assert_eq!(
storage.get_labels("bd-l-active").unwrap(),
vec!["renamed".to_string()]
);
assert_eq!(
storage.get_labels("bd-l-deleted").unwrap(),
vec!["legacy".to_string()]
);
}
#[test]
fn test_add_dependency_and_remove() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
let added = storage
.add_dependency("bd-a1", "bd-b1", "blocks", "tester")
.unwrap();
assert!(added);
let added = storage
.add_dependency("bd-a1", "bd-b1", "blocks", "tester")
.unwrap();
assert!(!added);
let deps = storage.get_dependencies("bd-a1").unwrap();
assert_eq!(deps, vec!["bd-b1".to_string()]);
let removed = storage
.remove_dependency("bd-a1", "bd-b1", "tester")
.unwrap();
assert!(removed);
let deps = storage.get_dependencies("bd-a1").unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_bulk_dependency_import_inserts_acyclic_edges_once() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-bulk-a", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-bulk-b", "B", Status::Open, 2, None, t1, None);
let issue_c = make_issue("bd-bulk-c", "C", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage.create_issue(&issue_c, "tester").unwrap();
let inserted = storage
.add_dependencies_bulk_for_import(
&[
BulkDependencyInsert {
issue_id: "bd-bulk-a".to_string(),
depends_on_id: "bd-bulk-b".to_string(),
dep_type: "blocks".to_string(),
},
BulkDependencyInsert {
issue_id: "bd-bulk-b".to_string(),
depends_on_id: "bd-bulk-c".to_string(),
dep_type: "blocks".to_string(),
},
BulkDependencyInsert {
issue_id: "bd-bulk-a".to_string(),
depends_on_id: "bd-bulk-b".to_string(),
dep_type: "blocks".to_string(),
},
],
"tester",
)
.unwrap();
assert_eq!(inserted, 2);
assert_eq!(
storage.get_dependencies("bd-bulk-a").unwrap(),
vec!["bd-bulk-b".to_string()]
);
assert_eq!(
storage.get_dependencies("bd-bulk-b").unwrap(),
vec!["bd-bulk-c".to_string()]
);
}
#[test]
fn test_bulk_dependency_import_rejects_cycle_before_partial_insert() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-bulk-cycle-a", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-bulk-cycle-b", "B", Status::Open, 2, None, t1, None);
let issue_c = make_issue("bd-bulk-cycle-c", "C", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage.create_issue(&issue_c, "tester").unwrap();
storage
.add_dependency("bd-bulk-cycle-a", "bd-bulk-cycle-b", "blocks", "tester")
.unwrap();
let error = storage
.add_dependencies_bulk_for_import(
&[
BulkDependencyInsert {
issue_id: "bd-bulk-cycle-b".to_string(),
depends_on_id: "bd-bulk-cycle-c".to_string(),
dep_type: "blocks".to_string(),
},
BulkDependencyInsert {
issue_id: "bd-bulk-cycle-c".to_string(),
depends_on_id: "bd-bulk-cycle-a".to_string(),
dep_type: "blocks".to_string(),
},
],
"tester",
)
.unwrap_err();
assert!(
matches!(error, BeadsError::DependencyCycle { .. }),
"unexpected bulk cycle error: {error:?}"
);
assert!(
storage
.get_dependencies("bd-bulk-cycle-b")
.unwrap()
.is_empty()
);
assert!(
storage
.get_dependencies("bd-bulk-cycle-c")
.unwrap()
.is_empty()
);
}
#[test]
fn test_add_dependency_existing_pair_skips_cycle_check() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-existing-a", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-existing-b", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage
.add_dependency("bd-existing-a", "bd-existing-b", "related", "tester")
.unwrap();
storage
.add_dependency("bd-existing-b", "bd-existing-a", "blocks", "tester")
.unwrap();
let added = storage
.add_dependency("bd-existing-a", "bd-existing-b", "blocks", "tester")
.expect("existing pair should return unchanged instead of false cycle");
assert!(!added);
let dep_types: Vec<String> = storage
.get_dependencies_full("bd-existing-a")
.unwrap()
.into_iter()
.map(|dep| dep.dep_type.as_str().to_string())
.collect();
assert_eq!(dep_types, vec!["related".to_string()]);
}
#[test]
fn test_bulk_dependency_import_ignores_existing_pairs_before_cycle_check() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-bulk-existing-a", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-bulk-existing-b", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage
.add_dependency(
"bd-bulk-existing-a",
"bd-bulk-existing-b",
"related",
"tester",
)
.unwrap();
let inserted = storage
.add_dependencies_bulk_for_import(
&[
BulkDependencyInsert {
issue_id: "bd-bulk-existing-a".to_string(),
depends_on_id: "bd-bulk-existing-b".to_string(),
dep_type: "blocks".to_string(),
},
BulkDependencyInsert {
issue_id: "bd-bulk-existing-b".to_string(),
depends_on_id: "bd-bulk-existing-a".to_string(),
dep_type: "blocks".to_string(),
},
],
"tester",
)
.expect("ignored duplicate pair should not create a false proposed cycle");
assert_eq!(inserted, 1);
let dep_types_a: Vec<String> = storage
.get_dependencies_full("bd-bulk-existing-a")
.unwrap()
.into_iter()
.map(|dep| dep.dep_type.as_str().to_string())
.collect();
assert_eq!(dep_types_a, vec!["related".to_string()]);
assert_eq!(
storage.get_dependencies("bd-bulk-existing-b").unwrap(),
vec!["bd-bulk-existing-a".to_string()]
);
}
#[test]
fn test_parent_child_cycle_check_allows_existing_parent_to_child_blocker_edge() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let parent = make_issue(
"bd-pc-cycle-parent",
"Parent",
Status::Open,
2,
None,
t1,
None,
);
let child = make_issue(
"bd-pc-cycle-child",
"Child",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
storage
.add_dependency(
"bd-pc-cycle-parent",
"bd-pc-cycle-child",
"blocks",
"tester",
)
.unwrap();
assert!(
storage
.would_create_cycle("bd-pc-cycle-child", "bd-pc-cycle-parent", true)
.unwrap(),
"standard edge semantics would see the existing parent -> child blocker path"
);
assert!(
!storage
.would_create_parent_child_cycle("bd-pc-cycle-child", "bd-pc-cycle-parent", true)
.unwrap(),
"parent-child semantics must check the prospective parent -> child graph edge"
);
assert!(
storage
.add_dependency(
"bd-pc-cycle-child",
"bd-pc-cycle-parent",
"parent-child",
"tester",
)
.unwrap()
);
assert!(
storage.detect_blocking_cycles().unwrap().is_empty(),
"duplicate parent -> child graph edges are acyclic"
);
}
#[test]
fn test_parent_child_cycle_check_rejects_reversed_hierarchy_cycle() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let parent = make_issue(
"bd-pc-cycle-existing-parent",
"Existing parent",
Status::Open,
2,
None,
t1,
None,
);
let child = make_issue(
"bd-pc-cycle-existing-child",
"Existing child",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
storage
.add_dependency(
"bd-pc-cycle-existing-child",
"bd-pc-cycle-existing-parent",
"parent-child",
"tester",
)
.unwrap();
assert!(
storage
.would_create_parent_child_cycle(
"bd-pc-cycle-existing-parent",
"bd-pc-cycle-existing-child",
true,
)
.unwrap(),
"making the existing parent a child of its child must be rejected"
);
let error = storage
.add_dependency(
"bd-pc-cycle-existing-parent",
"bd-pc-cycle-existing-child",
"parent-child",
"tester",
)
.unwrap_err();
assert!(
matches!(error, BeadsError::DependencyCycle { .. }),
"unexpected reversed hierarchy error: {error:?}"
);
assert_eq!(
storage
.get_parent_id("bd-pc-cycle-existing-parent")
.unwrap(),
None
);
}
#[test]
fn test_add_dependency_rejects_second_parent_child_parent() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let child = make_issue(
"bd-single-parent-child",
"Child",
Status::Open,
2,
None,
t1,
None,
);
let parent_a = make_issue(
"bd-single-parent-a",
"Parent A",
Status::Open,
2,
None,
t1,
None,
);
let parent_b = make_issue(
"bd-single-parent-b",
"Parent B",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&parent_a, "tester").unwrap();
storage.create_issue(&parent_b, "tester").unwrap();
assert!(
storage
.add_dependency(
"bd-single-parent-child",
"bd-single-parent-a",
"Parent-Child",
"tester",
)
.unwrap()
);
assert!(
!storage
.add_dependency(
"bd-single-parent-child",
"bd-single-parent-a",
"parent-child",
"tester",
)
.unwrap(),
"adding the same parent-child row should remain idempotent"
);
let error = storage
.add_dependency(
"bd-single-parent-child",
"bd-single-parent-b",
"parent-child",
"tester",
)
.unwrap_err();
assert!(
matches!(
&error,
BeadsError::Validation { field, reason }
if field == "depends_on_id"
&& reason.contains("already has parent bd-single-parent-a")
),
"unexpected second-parent error: {error:?}"
);
assert_eq!(
storage
.get_parent_id("bd-single-parent-child")
.unwrap()
.as_deref(),
Some("bd-single-parent-a")
);
}
#[test]
fn test_dependency_mutations_reject_tombstone_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let child = make_issue("bd-dep-child", "Child", Status::Open, 2, None, t1, None);
let old_parent = make_issue(
"bd-dep-old-parent",
"Old parent",
Status::Open,
2,
None,
t1,
None,
);
let new_parent = make_issue(
"bd-dep-new-parent",
"New parent",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&old_parent, "tester").unwrap();
storage.create_issue(&new_parent, "tester").unwrap();
storage
.set_parent("bd-dep-child", Some("bd-dep-old-parent"), "tester")
.unwrap();
storage
.delete_issue("bd-dep-child", "tester", "delete dependency target", None)
.unwrap();
let remove_error = storage
.remove_dependency("bd-dep-child", "bd-dep-old-parent", "tester")
.unwrap_err();
assert!(
matches!(
&remove_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains(
"cannot remove dependency from tombstone issue: bd-dep-child"
)
),
"unexpected remove_dependency error: {remove_error:?}"
);
let clear_parent_error = storage
.set_parent("bd-dep-child", None, "tester")
.unwrap_err();
assert!(
matches!(
&clear_parent_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains(
"cannot clear parent from tombstone issue: bd-dep-child"
)
),
"unexpected clear parent error: {clear_parent_error:?}"
);
let remove_parent_error = storage.remove_parent("bd-dep-child", "tester").unwrap_err();
assert!(
matches!(
&remove_parent_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains(
"cannot clear parent from tombstone issue: bd-dep-child"
)
),
"unexpected remove_parent error: {remove_parent_error:?}"
);
let set_parent_error = storage
.set_parent("bd-dep-child", Some("bd-dep-new-parent"), "tester")
.unwrap_err();
assert!(
matches!(
&set_parent_error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains("cannot set parent on tombstone issue: bd-dep-child")
),
"unexpected set parent error: {set_parent_error:?}"
);
let deps = storage.get_dependencies("bd-dep-child").unwrap();
assert_eq!(deps, vec!["bd-dep-old-parent".to_string()]);
}
#[test]
fn test_set_parent_same_parent_is_noop() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 3, 0, 0, 0).unwrap();
let child = make_issue(
"bd-parent-noop-child",
"Child",
Status::Open,
2,
None,
t1,
None,
);
let parent = make_issue(
"bd-parent-noop-parent",
"Parent",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&parent, "tester").unwrap();
storage
.set_parent(
"bd-parent-noop-child",
Some("bd-parent-noop-parent"),
"tester",
)
.unwrap();
storage.clear_all_dirty_issues().unwrap();
let before = storage
.get_issue("bd-parent-noop-child")
.unwrap()
.expect("child exists");
let event_count_before = storage
.get_events("bd-parent-noop-child", 100)
.unwrap()
.len();
storage
.set_parent(
"bd-parent-noop-child",
Some("bd-parent-noop-parent"),
"tester",
)
.unwrap();
let after = storage
.get_issue("bd-parent-noop-child")
.unwrap()
.expect("child exists");
assert_eq!(after.updated_at, before.updated_at);
assert_eq!(
storage
.get_parent_id("bd-parent-noop-child")
.unwrap()
.as_deref(),
Some("bd-parent-noop-parent")
);
assert_eq!(
storage
.get_events("bd-parent-noop-child", 100)
.unwrap()
.len(),
event_count_before
);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_set_parent_same_requested_parent_cleans_extra_parent_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 5, 0, 0, 0).unwrap();
let child = make_issue(
"bd-parent-cleanup-child",
"Child",
Status::Open,
2,
None,
t1,
None,
);
let parent = make_issue(
"bd-parent-cleanup-parent",
"Parent",
Status::Open,
2,
None,
t1,
None,
);
let extra_parent = make_issue(
"bd-parent-cleanup-extra-parent",
"Extra parent",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&extra_parent, "tester").unwrap();
insert_parent_child_dependency_for_test(
&storage,
"bd-parent-cleanup-child",
"bd-parent-cleanup-parent",
t1,
);
insert_parent_child_dependency_for_test(
&storage,
"bd-parent-cleanup-child",
"bd-parent-cleanup-extra-parent",
t1,
);
assert_eq!(
storage
.get_parent_id("bd-parent-cleanup-child")
.unwrap()
.as_deref(),
Some("bd-parent-cleanup-extra-parent")
);
storage.clear_all_dirty_issues().unwrap();
storage
.set_parent(
"bd-parent-cleanup-child",
Some("bd-parent-cleanup-parent"),
"tester",
)
.unwrap();
assert_eq!(
storage
.get_parent_id("bd-parent-cleanup-child")
.unwrap()
.as_deref(),
Some("bd-parent-cleanup-parent")
);
let parent_rows = storage
.conn
.query_with_params(
"SELECT depends_on_id FROM dependencies WHERE issue_id = ? AND type = 'parent-child' ORDER BY depends_on_id",
&[SqliteValue::from("bd-parent-cleanup-child")],
)
.unwrap();
let parent_ids = parent_rows
.iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect::<Vec<_>>();
assert_eq!(
parent_ids,
vec!["bd-parent-cleanup-parent".to_string()],
"set_parent must canonicalize duplicate parent-child rows"
);
assert_eq!(
storage.get_dirty_issue_ids().unwrap(),
vec!["bd-parent-cleanup-child".to_string()]
);
}
#[test]
fn test_remove_parent_invalidates_all_duplicate_parent_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 6, 0, 0, 0).unwrap();
let child = make_issue(
"bd-remove-parent-child",
"Child",
Status::Open,
2,
None,
t1,
None,
);
let mut parent_a = make_issue(
"bd-remove-parent-a",
"Parent A",
Status::Open,
2,
None,
t1,
None,
);
parent_a.issue_type = IssueType::Epic;
let mut parent_b = make_issue(
"bd-remove-parent-b",
"Parent B",
Status::Open,
2,
None,
t1,
None,
);
parent_b.issue_type = IssueType::Epic;
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&parent_a, "tester").unwrap();
storage.create_issue(&parent_b, "tester").unwrap();
insert_parent_child_dependency_for_test(
&storage,
"bd-remove-parent-child",
"bd-remove-parent-a",
t1,
);
insert_parent_child_dependency_for_test(
&storage,
"bd-remove-parent-child",
"bd-remove-parent-b",
t1,
);
storage.rebuild_blocked_cache(true).unwrap();
assert!(storage.is_blocked("bd-remove-parent-a").unwrap());
assert!(storage.is_blocked("bd-remove-parent-b").unwrap());
assert!(
storage
.remove_parent("bd-remove-parent-child", "tester")
.unwrap()
);
assert!(
!storage.blocked_cache_marked_stale().unwrap(),
"remove_parent should eagerly refresh the affected cache entries"
);
assert!(
!storage.is_blocked("bd-remove-parent-a").unwrap(),
"first old parent must not keep a stale child-open cache row"
);
assert!(
!storage.is_blocked("bd-remove-parent-b").unwrap(),
"second old parent must not keep a stale child-open cache row"
);
assert_eq!(
storage.get_parent_id("bd-remove-parent-child").unwrap(),
None
);
}
#[test]
fn test_set_parent_clear_absent_parent_is_noop() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-parent-clear-noop",
"Already root",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
storage.clear_all_dirty_issues().unwrap();
let before = storage
.get_issue("bd-parent-clear-noop")
.unwrap()
.expect("issue exists");
let event_count_before = storage
.get_events("bd-parent-clear-noop", 100)
.unwrap()
.len();
storage
.set_parent("bd-parent-clear-noop", None, "tester")
.unwrap();
let after = storage
.get_issue("bd-parent-clear-noop")
.unwrap()
.expect("issue exists");
assert_eq!(after.updated_at, before.updated_at);
assert_eq!(storage.get_parent_id("bd-parent-clear-noop").unwrap(), None);
assert_eq!(
storage
.get_events("bd-parent-clear-noop", 100)
.unwrap()
.len(),
event_count_before
);
assert!(storage.get_dirty_issue_ids().unwrap().is_empty());
}
#[test]
fn test_add_dependency_rejects_missing_target() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
let err = storage
.add_dependency("bd-a1", "bd-missing", "blocks", "tester")
.unwrap_err();
assert!(matches!(err, BeadsError::IssueNotFound { id } if id == "bd-missing"));
}
#[test]
fn test_add_dependency_with_metadata_persists_json() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage
.add_dependency_with_metadata(
"bd-a1",
"bd-b1",
"blocks",
"tester",
Some(r#"{"source":"cli","reason":"gate"}"#),
)
.unwrap();
let deps = storage.get_dependencies_full("bd-a1").unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(
deps[0].metadata.as_deref(),
Some(r#"{"source":"cli","reason":"gate"}"#)
);
}
#[test]
fn test_import_dependency_with_legacy_empty_metadata_rebuilds() {
// Regression for issue #323: legacy JSONL with `"metadata":""` (an
// empty string that is not valid JSON) must rebuild cleanly through the
// JSONL deserialize -> SQLite import path, materializing as the empty
// JSON object `"{}"` with no data loss.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
// Deserialize exactly as a JSONL rebuild would (not constructing the
// Dependency directly) so the empty-string coercion is exercised.
let dep_json = r#"{
"issue_id": "bd-a1",
"depends_on_id": "bd-b1",
"type": "blocks",
"created_at": "2026-01-01T00:00:00Z",
"created_by": "import",
"metadata": "",
"thread_id": ""
}"#;
let dep: crate::model::Dependency =
serde_json::from_str(dep_json).expect("legacy empty metadata must deserialize");
assert_eq!(dep.metadata, None, "empty metadata must coerce to None");
storage
.sync_dependencies_for_import("bd-a1", &[dep])
.expect("rebuild with legacy empty metadata must succeed");
let deps = storage.get_dependencies_full("bd-a1").unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].depends_on_id, "bd-b1");
// None is materialized as "{}" on insert.
assert_eq!(deps[0].metadata.as_deref(), Some("{}"));
}
#[test]
fn test_get_dependencies_full_preserves_numeric_created_at() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage
.add_dependency("bd-a1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute_with_params(
"UPDATE dependencies SET created_at = ? WHERE issue_id = ? AND depends_on_id = ?",
&[
SqliteValue::Integer(1_776_651_488_000_000),
SqliteValue::from("bd-a1"),
SqliteValue::from("bd-b1"),
],
)
.unwrap();
let deps = storage.get_dependencies_full("bd-a1").unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].created_at.year(), 2026);
assert_eq!(deps[0].created_at.month(), 4);
assert_eq!(deps[0].created_at.day(), 20);
assert_eq!(deps[0].created_at.hour(), 2);
assert_eq!(deps[0].created_at.minute(), 18);
let by_issue = storage
.get_dependencies_full_for_issues(&["bd-a1".to_string()])
.unwrap();
assert_eq!(by_issue["bd-a1"][0].created_at, deps[0].created_at);
}
#[test]
fn test_add_dependency_with_metadata_rejects_invalid_json() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
let err = storage
.add_dependency_with_metadata("bd-a1", "bd-b1", "blocks", "tester", Some("{not-json"))
.unwrap_err();
assert!(matches!(err, BeadsError::Validation { field, .. } if field == "metadata"));
}
#[test]
fn test_find_ids_by_hash_only_matches_hash_portion() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("my-proj-abc123", "Alpha", Status::Open, 2, None, t1, None);
let issue_b = make_issue("other-proj-xyz789", "Beta", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
let matches = storage.find_ids_by_hash("proj").unwrap();
assert!(
matches.is_empty(),
"prefix fragments must not match hash lookup"
);
assert_eq!(
storage.find_ids_by_hash("abc").unwrap(),
vec!["my-proj-abc123".to_string()]
);
}
#[test]
fn test_get_dependencies_with_metadata_external_placeholder() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage
.add_dependency("bd-a1", "external:proj:capability", "blocks", "tester")
.unwrap();
let deps = storage.get_dependencies_with_metadata("bd-a1").unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].id, "external:proj:capability");
assert_eq!(deps[0].title, "proj:capability");
assert_eq!(deps[0].status, Status::Blocked);
assert_eq!(deps[0].priority, Priority::MEDIUM);
assert_eq!(deps[0].dep_type, "blocks");
}
#[test]
fn test_get_dependencies_with_metadata_sorts_external_placeholder_as_medium_priority() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
let critical = make_issue("bd-critical", "Critical", Status::Open, 0, None, t1, None);
let low = make_issue("bd-low", "Low", Status::Open, 3, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&critical, "tester").unwrap();
storage.create_issue(&low, "tester").unwrap();
storage
.add_dependency("bd-a1", "external:proj:capability", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-a1", "bd-low", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-a1", "bd-critical", "blocks", "tester")
.unwrap();
let deps = storage.get_dependencies_with_metadata("bd-a1").unwrap();
let ids: Vec<_> = deps.iter().map(|dep| dep.id.as_str()).collect();
assert_eq!(
ids,
vec!["bd-critical", "external:proj:capability", "bd-low"]
);
assert_eq!(deps[1].priority, Priority::MEDIUM);
}
#[test]
fn test_get_dependencies_with_metadata_errors_on_missing_internal_target() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a1", "A", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
let created_at = Utc::now().to_rfc3339();
storage
.execute_test_sql(&format!(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES ('bd-a1', 'bd-missing', 'blocks', '{created_at}', 'tester')"
))
.unwrap();
let deps = storage
.get_dependencies_with_metadata("bd-a1")
.expect("should return placeholder for missing dependency");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].id, "bd-missing");
assert_eq!(deps[0].title, "[missing issue: bd-missing]");
assert_eq!(deps[0].status, Status::Tombstone);
}
#[test]
fn test_get_dependents_with_metadata_errors_on_missing_dependent_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 2, 0, 0, 0).unwrap();
let issue_b = make_issue("bd-b1", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_b, "tester").unwrap();
let created_at = Utc::now().to_rfc3339();
storage
.execute_test_sql(&format!(
"PRAGMA foreign_keys = OFF;
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES ('bd-missing', 'bd-b1', 'blocks', '{created_at}', 'tester');
PRAGMA foreign_keys = ON;"
))
.unwrap();
let deps = storage
.get_dependents_with_metadata("bd-b1")
.expect("should return placeholder for missing dependent");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].id, "bd-missing");
assert_eq!(deps[0].title, "[missing issue: bd-missing]");
assert_eq!(deps[0].status, Status::Tombstone);
}
#[test]
fn test_would_create_cycle_detects_cycle() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 3, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-cy1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-cy2", "B", Status::Open, 2, None, t1, None);
let issue_c = make_issue("bd-cy3", "C", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage.create_issue(&issue_c, "tester").unwrap();
storage
.add_dependency("bd-cy1", "bd-cy2", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-cy2", "bd-cy3", "blocks", "tester")
.unwrap();
let creates_cycle = storage
.would_create_cycle("bd-cy3", "bd-cy1", true)
.unwrap();
assert!(creates_cycle);
}
#[test]
fn test_detect_blocking_cycles_ignores_related_edges() -> Result<()> {
let mut storage = SqliteStorage::open_memory()?;
let t1 = Utc
.with_ymd_and_hms(2025, 7, 3, 0, 0, 0)
.single()
.ok_or_else(|| BeadsError::internal("invalid test timestamp"))?;
let issue_a = make_issue("bd-rel-cy1", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-rel-cy2", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester")?;
storage.create_issue(&issue_b, "tester")?;
storage.add_dependency("bd-rel-cy1", "bd-rel-cy2", "related", "tester")?;
storage.add_dependency("bd-rel-cy2", "bd-rel-cy1", "related", "tester")?;
// GitHub #391: `related` edges are never cycle-checked when added,
// so no report mode may count them — the default previously did,
// making `br dep cycles` fail on graphs the add path allowed.
assert!(storage.detect_all_cycles()?.is_empty());
assert!(storage.detect_blocking_cycles()?.is_empty());
Ok(())
}
#[test]
fn test_get_comments_orders_by_created_at() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = Issue {
id: "bd-c1".to_string(),
content_hash: None,
title: "Comment issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: t1,
created_by: None,
updated_at: t1,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from("bd-c1"),
SqliteValue::from("alice"),
SqliteValue::from("first"),
SqliteValue::from("2025-07-01T00:00:00Z"),
],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from("bd-c1"),
SqliteValue::from("bob"),
SqliteValue::from("second"),
SqliteValue::from("2025-07-02T00:00:00Z"),
],
)
.unwrap();
let comments = storage.get_comments("bd-c1").unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].author, "alice");
assert_eq!(comments[1].author, "bob");
}
#[test]
fn test_get_comments_errors_on_invalid_timestamp() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = Issue {
id: "bd-c-invalid".to_string(),
content_hash: None,
title: "Comment issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: t1,
created_by: None,
updated_at: t1,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from("bd-c-invalid"),
SqliteValue::from("alice"),
SqliteValue::from("first"),
SqliteValue::from("not-a-real-timestamp"),
],
)
.unwrap();
let err = storage.get_comments("bd-c-invalid").unwrap_err();
assert!(
matches!(
&err,
BeadsError::Config(msg)
if msg.contains("invalid comment timestamp")
&& msg.contains("unparseable datetime")
),
"unexpected error: {err:?}"
);
}
#[test]
fn test_get_comments_preserves_numeric_created_at() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = Issue {
id: "bd-c-numeric-time".to_string(),
content_hash: None,
title: "Comment issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: t1,
created_by: None,
updated_at: t1,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from("bd-c-numeric-time"),
SqliteValue::from("alice"),
SqliteValue::from("fractional"),
SqliteValue::Float(1_776_651_488.25),
],
)
.unwrap();
let comments = storage.get_comments("bd-c-numeric-time").unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].created_at.year(), 2026);
assert_eq!(comments[0].created_at.timestamp(), 1_776_651_488);
assert_eq!(comments[0].created_at.timestamp_subsec_nanos(), 250_000_000);
let by_issue = storage
.get_comments_for_issues(&["bd-c-numeric-time".to_string()])
.unwrap();
assert_eq!(
by_issue["bd-c-numeric-time"][0].created_at,
comments[0].created_at
);
}
#[test]
fn test_get_latest_comments_for_issues_bounds_each_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-c-latest-a", "A", Status::Open, 2, None, t1, None);
let issue_b = make_issue("bd-c-latest-b", "B", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
for (issue_id, body, created_at) in [
("bd-c-latest-a", "a-old", "2025-07-01T00:00:00Z"),
("bd-c-latest-a", "a-middle", "2025-07-02T00:00:00Z"),
("bd-c-latest-a", "a-new", "2025-07-03T00:00:00Z"),
("bd-c-latest-b", "b-old", "2025-07-01T00:00:00Z"),
("bd-c-latest-b", "b-new", "2025-07-02T00:00:00Z"),
] {
storage
.conn
.execute_with_params(
"INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
&[
SqliteValue::from(issue_id),
SqliteValue::from("tester"),
SqliteValue::from(body),
SqliteValue::from(created_at),
],
)
.unwrap();
}
let by_issue = storage
.get_latest_comments_for_issues(
&["bd-c-latest-a".to_string(), "bd-c-latest-b".to_string()],
2,
)
.unwrap();
let issue_a_bodies = by_issue["bd-c-latest-a"]
.iter()
.map(|comment| comment.body.as_str())
.collect::<Vec<_>>();
let issue_b_bodies = by_issue["bd-c-latest-b"]
.iter()
.map(|comment| comment.body.as_str())
.collect::<Vec<_>>();
assert_eq!(issue_a_bodies, ["a-middle", "a-new"]);
assert_eq!(issue_b_bodies, ["b-old", "b-new"]);
let empty = storage
.get_latest_comments_for_issues(&["bd-c-latest-a".to_string()], 0)
.unwrap();
assert!(empty.is_empty());
}
#[test]
fn test_add_comment_round_trip() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = Issue {
id: "bd-c2".to_string(),
content_hash: None,
title: "Comment issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: t1,
created_by: None,
updated_at: t1,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
let comment = storage
.add_comment("bd-c2", "alice", "Hello there")
.unwrap();
assert_eq!(comment.issue_id, "bd-c2");
assert_eq!(comment.author, "alice");
assert_eq!(comment.body, "Hello there");
assert!(comment.id > 0);
let comments = storage.get_comments("bd-c2").unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0], comment);
}
#[test]
fn test_add_comment_rejects_invalid_comment_fields_without_inserting() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-c-invalid-input",
"Comment validation target",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
// Long-content comments are explicitly permitted — spec write-ups,
// session transcripts, etc. routinely exceed 50KB. The prior cap
// rejected legitimate pre-existing JSONL records on rebuild.
let long_text = "x".repeat(600_000);
storage
.add_comment("bd-c-invalid-input", "alice", &long_text)
.expect("long comment bodies must be accepted");
let author_error = storage
.add_comment("bd-c-invalid-input", "", "Valid comment body")
.unwrap_err();
assert!(
matches!(
&author_error,
BeadsError::Validation { field, reason }
if field == "author" && reason.contains("cannot be empty")
),
"unexpected empty author error: {author_error:?}"
);
let comments = storage.get_comments("bd-c-invalid-input").unwrap();
assert_eq!(
comments.len(),
1,
"the long-body insert above must persist; only the empty-author insert is rejected: {comments:?}"
);
assert_eq!(
comments[0].body.len(),
600_000,
"long comment body preserved verbatim"
);
}
#[test]
fn test_create_issue_rejects_invalid_embedded_comment_without_inserting_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let mut issue = make_issue(
"bd-c-create-invalid",
"Create comment validation target",
Status::Open,
2,
None,
t1,
None,
);
issue.comments.push(Comment {
id: 1,
issue_id: "bd-c-create-invalid".to_string(),
author: "alice".to_string(),
body: " ".to_string(),
created_at: t1,
});
let err = storage.create_issue(&issue, "tester").unwrap_err();
assert!(
matches!(
&err,
BeadsError::Validation { field, reason }
if field == "content" && reason.contains("cannot be empty")
),
"unexpected embedded comment validation error: {err:?}"
);
assert!(storage.get_issue("bd-c-create-invalid").unwrap().is_none());
assert!(
storage
.get_comments("bd-c-create-invalid")
.unwrap()
.is_empty()
);
}
#[test]
fn test_add_comment_rejects_tombstone_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-c-tomb",
"Deleted comment target",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let original_comment = storage
.add_comment("bd-c-tomb", "alice", "Before delete")
.unwrap();
storage
.delete_issue("bd-c-tomb", "tester", "delete comment target", None)
.unwrap();
let error = storage
.add_comment("bd-c-tomb", "bob", "After delete")
.unwrap_err();
assert!(
matches!(
&error,
BeadsError::Validation { field, reason }
if field == "issue_id"
&& reason.contains("cannot add comment to tombstone issue: bd-c-tomb")
),
"unexpected add_comment error: {error:?}"
);
let comments = storage.get_comments("bd-c-tomb").unwrap();
assert_eq!(comments, vec![original_comment]);
}
#[test]
fn test_sync_comments_for_import_preserves_comments_on_other_issues() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue_a = make_issue(
"bd-c-import-a",
"Import target",
Status::Open,
2,
None,
t1,
None,
);
let issue_b = make_issue(
"bd-c-import-b",
"Existing comment owner",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
let existing_comment = storage
.add_comment("bd-c-import-b", "bob", "Existing comment")
.unwrap();
let imported_comment = crate::model::Comment {
id: existing_comment.id,
issue_id: "bd-c-import-a".to_string(),
author: "alice".to_string(),
body: "Imported comment".to_string(),
created_at: t1 + chrono::Duration::minutes(5),
};
storage
.sync_comments_for_import("bd-c-import-a", &[imported_comment])
.unwrap();
let comments_a = storage.get_comments("bd-c-import-a").unwrap();
assert_eq!(comments_a.len(), 1);
assert_eq!(comments_a[0].issue_id, "bd-c-import-a");
assert_eq!(comments_a[0].body, "Imported comment");
assert_ne!(comments_a[0].id, existing_comment.id);
let comments_b = storage.get_comments("bd-c-import-b").unwrap();
assert_eq!(comments_b, vec![existing_comment]);
}
#[test]
fn test_sync_comments_for_import_rejects_duplicate_comment_ids_for_same_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-c-import-dup",
"Duplicate import comments",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let existing_comment = storage
.add_comment(&issue.id, "bob", "existing local comment")
.unwrap();
let first = crate::model::Comment {
id: 42,
issue_id: issue.id.clone(),
author: "alice".to_string(),
body: "first imported comment".to_string(),
created_at: t1,
};
let second = crate::model::Comment {
id: 42,
issue_id: issue.id.clone(),
author: "alice".to_string(),
body: "duplicate imported comment".to_string(),
created_at: t1 + chrono::Duration::minutes(1),
};
let error = storage
.sync_comments_for_import(&issue.id, &[first, second])
.unwrap_err();
assert!(
error.to_string().contains("duplicate import comment id 42"),
"duplicate same-issue import comment IDs must remain invalid: {error:?}"
);
assert_eq!(
storage.get_comments(&issue.id).unwrap(),
vec![existing_comment.clone()]
);
let wrong_issue_comment = crate::model::Comment {
id: 44,
issue_id: "bd-c-import-other".to_string(),
author: "alice".to_string(),
body: "valid text, wrong owner".to_string(),
created_at: t1,
};
let error = storage
.sync_comments_for_import(&issue.id, &[wrong_issue_comment])
.unwrap_err();
assert!(
error.to_string().contains("comment.issue_id"),
"wrong comment owner must fail validation: {error:?}"
);
assert_eq!(
storage.get_comments(&issue.id).unwrap(),
vec![existing_comment]
);
}
#[test]
fn test_sync_comments_for_import_handles_auto_realloc_self_collision() {
// Issue #374: during a single import, an earlier comment whose JSONL id
// collides with a comment on *another* issue is AUTO-reallocated a fresh
// id via AUTOINCREMENT. If that freshly assigned id happens to equal a
// *later* comment's JSONL id in the SAME import, the later insert hits a
// PK collision whose owner is this very issue. The old narrow guard
// (`owner != issue_id`) turned that self-collision into a hard failure,
// corrupting rebuild-from-JSONL. It must instead reallocate again.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue_a = make_issue(
"bd-c-realloc-a",
"Import target",
Status::Open,
2,
None,
t1,
None,
);
let issue_b = make_issue(
"bd-c-realloc-b",
"Existing comment owner",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
// Existing comment on issue B occupies id E and makes E the current
// AUTOINCREMENT high-water mark, so the next auto id is E + 1.
let existing = storage
.add_comment("bd-c-realloc-b", "bob", "Existing comment on B")
.unwrap();
let e = existing.id;
// First imported comment collides with B's id (E) -> auto-realloc to
// E + 1. Second imported comment's JSONL id is E + 1, which now collides
// with the just-reallocated first comment -> a same-issue self-collision.
let first = crate::model::Comment {
id: e,
issue_id: "bd-c-realloc-a".to_string(),
author: "alice".to_string(),
body: "first imported (collides with B)".to_string(),
created_at: t1 + chrono::Duration::minutes(1),
};
let second = crate::model::Comment {
id: e + 1,
issue_id: "bd-c-realloc-a".to_string(),
author: "alice".to_string(),
body: "second imported (self-collision)".to_string(),
created_at: t1 + chrono::Duration::minutes(2),
};
storage
.sync_comments_for_import("bd-c-realloc-a", &[first, second])
.expect("auto-realloc self-collision must not fail the import");
let mut comments_a = storage.get_comments("bd-c-realloc-a").unwrap();
comments_a.sort_by_key(|c| c.created_at);
assert_eq!(comments_a.len(), 2, "both imported comments must be kept");
assert_eq!(comments_a[0].body, "first imported (collides with B)");
assert_eq!(comments_a[1].body, "second imported (self-collision)");
// Reallocated ids must be distinct and must not clobber B's comment.
assert_ne!(comments_a[0].id, comments_a[1].id);
assert_ne!(comments_a[0].id, e);
assert_ne!(comments_a[1].id, e);
// Issue B's original comment is untouched.
assert_eq!(
storage.get_comments("bd-c-realloc-b").unwrap(),
vec![existing]
);
}
#[test]
fn connection_user_version_sees_wal_resident_value_header_misses() {
// Issue #373: a user_version written through a connection lives in the
// uncheckpointed WAL, so a raw file-header peek reads a stale value
// while the connection observes the true one. connection_user_version
// must report the WAL-resident value; database_header_user_version must
// not — proving the exact scenario the open-path fix guards against.
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("wal_uv.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("PRAGMA journal_mode=WAL").unwrap();
conn.execute("CREATE TABLE t (x INTEGER)").unwrap();
conn.execute("PRAGMA user_version = 4242").unwrap();
// Through the connection: WAL-aware, sees the new value.
assert_eq!(connection_user_version(&conn), Some(4242));
// Raw header peek of the main db file misses the uncheckpointed value.
assert_ne!(
database_header_user_version(&db_path),
Some(4242),
"header peek unexpectedly reflected the WAL-resident user_version; \
the WAL-miss scenario cannot be proven"
);
let wal_preflight = sqlite_wal_schema_preflight(&db_path).unwrap();
assert_eq!(
wal_preflight.committed_user_version,
Some(4242),
"the byte-neutral WAL parser must recover page one's committed user_version"
);
}
#[test]
fn test_sync_comments_for_import_validates_before_replacing_existing_comments() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = make_issue(
"bd-c-import-invalid",
"Invalid import comments",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let existing_comment = storage
.add_comment(&issue.id, "bob", "existing local comment")
.unwrap();
let invalid_comment = crate::model::Comment {
id: 43,
issue_id: issue.id.clone(),
author: "alice".to_string(),
body: " ".to_string(),
created_at: t1,
};
let error = storage
.sync_comments_for_import(&issue.id, &[invalid_comment])
.unwrap_err();
assert!(
error.to_string().contains("cannot be empty"),
"invalid import comment body must fail validation: {error:?}"
);
assert_eq!(
storage.get_comments(&issue.id).unwrap(),
vec![existing_comment]
);
}
#[test]
fn test_insert_new_issue_relations_for_import_skips_relation_deletes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let parent = make_issue("bd-parent", "Parent", Status::Open, 2, None, t1, None);
let other = make_issue("bd-other", "Other", Status::Open, 2, None, t1, None);
let mut imported = make_issue("bd-new", "New import", Status::Open, 2, None, t1, None);
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&other, "tester").unwrap();
storage.insert_new_issue_for_import(&imported).unwrap();
let existing_comment = storage
.add_comment("bd-other", "bob", "Existing owner")
.unwrap();
imported.labels = vec!["sync".to_string(), "perf".to_string(), "sync".to_string()];
imported.dependencies = vec![crate::model::Dependency {
issue_id: imported.id.clone(),
depends_on_id: parent.id.clone(),
dep_type: crate::model::DependencyType::Blocks,
created_at: t1,
created_by: Some("import".to_string()),
metadata: None,
thread_id: None,
}];
imported.comments = vec![crate::model::Comment {
id: existing_comment.id,
issue_id: imported.id.clone(),
author: "alice".to_string(),
body: "Imported comment".to_string(),
created_at: t1 + chrono::Duration::minutes(5),
}];
storage
.insert_new_issue_relations_for_import(&imported)
.unwrap();
assert_eq!(
storage.get_labels(&imported.id).unwrap(),
vec!["perf", "sync"]
);
let dependency_count = storage
.execute_raw_query("SELECT COUNT(*) FROM dependencies WHERE issue_id = 'bd-new'")
.unwrap()
.first()
.and_then(|row| row.first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(dependency_count, 1);
let imported_comments = storage.get_comments(&imported.id).unwrap();
assert_eq!(imported_comments.len(), 1);
assert_eq!(imported_comments[0].body, "Imported comment");
assert_ne!(imported_comments[0].id, existing_comment.id);
assert_eq!(
storage.get_comments(&other.id).unwrap(),
vec![existing_comment]
);
}
#[test]
fn test_has_owned_relation_rows_for_import_detects_orphan_rows() {
let storage = SqliteStorage::open_memory().unwrap();
assert!(
!storage
.has_owned_relation_rows_for_import("bd-stale")
.unwrap()
);
storage
.conn
.execute(
"PRAGMA foreign_keys = OFF;
INSERT INTO labels (issue_id, label) VALUES ('bd-stale', 'old-label');",
)
.unwrap();
assert!(
storage
.has_owned_relation_rows_for_import("bd-stale")
.unwrap()
);
}
#[test]
fn test_external_project_capabilities_ignore_tombstones() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("external.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let mut closed_issue = make_issue(
"bd-cap-closed",
"Closed provider",
Status::Closed,
2,
None,
t1,
None,
);
closed_issue.closed_at = Some(t1);
let mut tombstone_issue = make_issue(
"bd-cap-tombstone",
"Deleted provider",
Status::Tombstone,
2,
None,
t1,
None,
);
tombstone_issue.deleted_at = Some(t1);
tombstone_issue.delete_reason = Some("deleted".to_string());
storage.create_issue(&closed_issue, "tester").unwrap();
storage.create_issue(&tombstone_issue, "tester").unwrap();
storage
.add_label("bd-cap-closed", "provides:closed-cap", "tester")
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO labels (issue_id, label) VALUES (?, ?)",
&[
SqliteValue::from("bd-cap-tombstone"),
SqliteValue::from("provides:deleted-cap"),
],
)
.unwrap();
drop(storage);
let capabilities = HashSet::from(["closed-cap".to_string(), "deleted-cap".to_string()]);
let satisfied = query_external_project_capabilities(&db_path, &capabilities).unwrap();
assert!(satisfied.contains("closed-cap"));
assert!(!satisfied.contains("deleted-cap"));
}
#[test]
fn test_external_project_capability_probe_does_not_create_missing_database() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("missing-external.db");
let capabilities = HashSet::from(["capability".to_string()]);
let error = query_external_project_capabilities(&db_path, &capabilities)
.expect_err("missing external database should be an unsatisfied dependency");
assert!(
error
.to_string()
.contains("external project database not found"),
"{error}"
);
assert!(
!db_path.exists(),
"read-only external dependency probe must not create missing DB files"
);
}
#[test]
fn test_add_comment_marks_dirty() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
let issue = Issue {
id: "bd-c3".to_string(),
content_hash: None,
title: "Comment issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: t1,
created_by: None,
updated_at: t1,
closed_at: None,
close_reason: None,
closed_by_session: None,
bypassed_policy: None,
bypass_reason: None,
policy_gates_fired: None,
defer_until: None,
due_at: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
storage.create_issue(&issue, "tester").unwrap();
storage
.add_comment("bd-c3", "alice", "Dirty comment")
.unwrap();
let dirty_count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM dirty_issues WHERE issue_id = ?",
&[SqliteValue::from("bd-c3")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(dirty_count, 1);
}
#[test]
fn test_events_have_timestamps() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = make_issue(
"bd-e1",
"Event Test",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue, "tester").unwrap();
// Verify event has timestamp
let created_at: String = storage
.conn
.query_row_with_params(
"SELECT created_at FROM events WHERE issue_id = ?",
&[SqliteValue::from("bd-e1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
// Should be a valid RFC3339 timestamp
assert!(
chrono::DateTime::parse_from_rfc3339(&created_at).is_ok(),
"Event timestamp should be valid RFC3339"
);
}
#[test]
fn test_blocked_cache_invalidation() {
let mut storage = SqliteStorage::open_memory().unwrap();
// Create issues first (required for FK constraints on events table)
let issue1 = make_issue(
"bd-c1",
"Cached issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue1, "tester").unwrap();
let issue2 = make_issue(
"bd-b1",
"Blocker issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&issue2, "tester").unwrap();
// Manually insert some cache data
storage
.conn
.execute_with_params(
"INSERT INTO blocked_issues_cache (issue_id, blocked_by) VALUES (?, ?)",
&[
SqliteValue::from("bd-c1"),
SqliteValue::from(r#"["bd-b1"]"#),
],
)
.unwrap();
// Verify cache has data
let count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM blocked_issues_cache WHERE issue_id = ?",
&[SqliteValue::from("bd-c1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(count, 1);
// Now add a non-blocking dependency type ("related" doesn't block)
storage
.add_dependency("bd-c1", "bd-b1", "related", "tester")
.unwrap();
// With deferred cache refresh, add_dependency now marks the cache stale
// rather than rebuilding immediately. Verify the stale marker was set.
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"cache should be marked stale after add_dependency"
);
// Read operations compute blocked state in memory when the cache is
// stale, WITHOUT persisting (#216 — read ops must not write).
let blocked = storage.is_blocked("bd-c1").unwrap();
assert!(
!blocked,
"bd-c1 should not be blocked after adding only a 'related' dep"
);
// The stale marker should remain — read ops do not clear it.
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"cache should still be stale after read (reads must not write)"
);
// The stale cache entry should still be in the table (not cleaned by reads).
let count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM blocked_issues_cache WHERE issue_id = ?",
&[SqliteValue::from("bd-c1")],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(0);
assert_eq!(
count, 1,
"stale cache entry should persist (reads must not mutate cache)"
);
}
#[test]
fn test_incremental_refresh_after_deferred_write_rebuilds_fully() {
// Regression: an Incremental blocked-cache refresh only recomputes its
// own parent-child component but then declares the whole cache fresh.
// If a prior Deferred write left the cache stale (its blocking edge is
// committed but not yet in blocked_issues_cache), an unrelated
// Incremental write must NOT clear the stale marker while that earlier
// change is still missing — it must upgrade to a Full rebuild.
let mut storage = SqliteStorage::open_memory().unwrap();
for (id, title) in [("bd-x", "X"), ("bd-y", "Y"), ("bd-z", "Z"), ("bd-w", "W")] {
let issue = make_issue(id, title, Status::Open, 2, None, Utc::now(), None);
storage.create_issue(&issue, "tester").unwrap();
}
// A "blocks" dependency uses the Deferred plan: commits the edge and
// marks the cache stale, but does NOT write blocked_issues_cache.
storage
.add_dependency("bd-x", "bd-y", "blocks", "tester")
.unwrap();
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"add_dependency must leave the cache stale"
);
// An UNRELATED set_parent uses the Incremental plan. bd-x is outside
// bd-z/bd-w's parent-child component, so a naive incremental refresh
// would clear the stale marker with bd-x still absent from the cache.
storage.set_parent("bd-z", Some("bd-w"), "tester").unwrap();
assert!(
!storage.blocked_cache_marked_stale().unwrap(),
"the incremental-after-deferred write should have fully rebuilt and cleared stale"
);
assert!(
storage.is_blocked("bd-x").unwrap(),
"bd-x must still be reported blocked by open bd-y after the unrelated \
incremental write (regression: incremental cleared stale with bd-x missing)"
);
}
fn persisted_blocked_cache(storage: &SqliteStorage) -> Vec<(String, String)> {
storage
.conn
.query("SELECT issue_id, blocked_by FROM blocked_issues_cache ORDER BY issue_id")
.unwrap()
.iter()
.map(|row| {
(
row.get(0)
.and_then(SqliteValue::as_text)
.unwrap()
.to_string(),
row.get(1)
.and_then(SqliteValue::as_text)
.unwrap()
.to_string(),
)
})
.collect()
}
#[test]
fn test_full_blocked_cache_invalidation_dominates_incremental() {
for full_first in [false, true] {
let mut ctx = MutationContext::new("test", "tester");
if full_first {
ctx.invalidate_cache();
ctx.invalidate_cache_for(&["bd-a"]);
} else {
ctx.invalidate_cache_for(&["bd-a"]);
ctx.invalidate_cache();
}
ctx.invalidate_cache_for(&["bd-b"]);
assert!(matches!(
BlockedCacheRefreshPlan::from_context(&ctx),
Some(BlockedCacheRefreshPlan::Full)
));
}
}
#[test]
fn test_status_batch_refreshes_persisted_blockers_selectively() {
let mut storage = SqliteStorage::open_memory().unwrap();
for id in [
"bd-a",
"bd-b",
"bd-blocks",
"bd-waits",
"bd-conditional",
"bd-related",
"bd-other",
"bd-other-blocker",
] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 2, None, Utc::now(), None),
"tester",
)
.unwrap();
}
for (id, kind) in [
("bd-blocks", "blocks"),
("bd-waits", "waits-for"),
("bd-conditional", "conditional-blocks"),
("bd-related", "related"),
] {
storage.add_dependency(id, "bd-a", kind, "tester").unwrap();
}
storage
.add_dependency("bd-blocks", "bd-b", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-other", "bd-other-blocker", "blocks", "tester")
.unwrap();
storage.ensure_blocked_cache_fresh().unwrap();
storage.conn.execute("UPDATE blocked_issues_cache SET blocked_at = '2000-01-01' WHERE issue_id = 'bd-other'").unwrap();
// Closing only one blocker must preserve the second blocker. Query the
// persisted rows, not getters that can silently compute from the graph.
storage
.update_issue(
"bd-a",
&IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
"tester",
)
.unwrap();
assert_eq!(
persisted_blocked_cache(&storage),
vec![
("bd-blocks".to_string(), r#"["bd-b:open"]"#.to_string()),
(
"bd-other".to_string(),
r#"["bd-other-blocker:open"]"#.to_string()
),
]
);
let untouched = storage
.conn
.query_row("SELECT blocked_at FROM blocked_issues_cache WHERE issue_id = 'bd-other'")
.unwrap();
assert_eq!(
untouched.get(0).and_then(SqliteValue::as_text),
Some("2000-01-01")
);
assert!(!storage.blocked_cache_marked_stale().unwrap());
// One atomic batch must merge both invalidation sets, and reopening
// restores all three blocking types without treating related as one.
storage
.update_issues_atomically(
&[
(
"bd-a".to_string(),
IssueUpdate {
status: Some(Status::InProgress),
..IssueUpdate::default()
},
),
(
"bd-b".to_string(),
IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
),
],
"tester",
)
.unwrap();
assert_eq!(
persisted_blocked_cache(&storage),
[
("bd-blocks", r#"["bd-a:in_progress"]"#),
("bd-conditional", r#"["bd-a:in_progress"]"#),
("bd-other", r#"["bd-other-blocker:open"]"#),
("bd-waits", r#"["bd-a:in_progress"]"#),
]
.into_iter()
.map(|(id, blockers)| (id.to_string(), blockers.to_string()))
.collect::<Vec<_>>()
);
assert!(!storage.blocked_cache_marked_stale().unwrap());
}
#[test]
fn test_status_batch_refreshes_dependents_across_query_chunks() {
let mut storage = SqliteStorage::open_memory().unwrap();
let ids: Vec<_> = (0..401)
.map(|index| format!("bd-blocker-{index}"))
.collect();
for id in ids
.iter()
.map(String::as_str)
.chain(["bd-first", "bd-last"])
{
storage
.create_issue(
&make_issue(id, id, Status::Open, 2, None, Utc::now(), None),
"tester",
)
.unwrap();
}
for (dependent, blocker) in [("bd-first", &ids[0]), ("bd-last", &ids[400])] {
storage
.add_dependency(dependent, blocker, "blocks", "tester")
.unwrap();
}
storage.ensure_blocked_cache_fresh().unwrap();
assert_eq!(persisted_blocked_cache(&storage).len(), 2);
let updates: Vec<_> = ids
.into_iter()
.map(|id| {
(
id,
IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
)
})
.collect();
storage
.update_issues_atomically(&updates, "tester")
.unwrap();
assert!(persisted_blocked_cache(&storage).is_empty());
assert!(!storage.blocked_cache_marked_stale().unwrap());
assert!(storage.get_blockers("bd-first").unwrap().is_empty());
assert!(storage.get_blockers("bd-last").unwrap().is_empty());
}
#[test]
fn test_failed_incremental_cache_refresh_preserves_close_and_stale_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
for id in ["bd-a", "bd-b", "bd-dependent"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 2, None, Utc::now(), None),
"tester",
)
.unwrap();
}
for blocker in ["bd-a", "bd-b"] {
storage
.add_dependency("bd-dependent", blocker, "blocks", "tester")
.unwrap();
}
storage.ensure_blocked_cache_fresh().unwrap();
// A real SQL failure AFTER selective DELETE: inserts name blocked_at.
// Full post-commit repair will also fail, exercising durable staleness.
storage
.conn
.execute("ALTER TABLE blocked_issues_cache RENAME COLUMN blocked_at TO unavailable_at")
.unwrap();
storage
.update_issue(
"bd-a",
&IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
"tester",
)
.unwrap();
assert_eq!(
storage.get_issue("bd-a").unwrap().unwrap().status,
Status::Closed
);
assert!(storage.blocked_cache_marked_stale().unwrap());
assert!(SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap());
let rows = storage
.conn
.query("SELECT blocked_by FROM blocked_issues_cache")
.unwrap();
assert_eq!(
rows.len(),
1,
"failed cache writes must roll back their DELETEs"
);
assert_eq!(
rows[0].get(0).and_then(SqliteValue::as_text),
Some(r#"["bd-a:open","bd-b:open"]"#)
);
assert_eq!(storage.get_blockers("bd-dependent").unwrap(), vec!["bd-b"]);
let events = storage
.conn
.query("SELECT actor FROM events WHERE issue_id = 'bd-a' AND event_type = 'closed'")
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].get(0).and_then(SqliteValue::as_text),
Some("tester")
);
storage
.conn
.execute("ALTER TABLE blocked_issues_cache RENAME COLUMN unavailable_at TO blocked_at")
.unwrap();
assert!(storage.ensure_blocked_cache_fresh().unwrap());
let rows = storage
.conn
.query("SELECT blocked_by FROM blocked_issues_cache")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].get(0).and_then(SqliteValue::as_text),
Some(r#"["bd-b:open"]"#)
);
}
#[test]
fn test_status_and_type_batch_refreshes_unrelated_epic_rollup() {
let mut storage = SqliteStorage::open_memory().unwrap();
for id in ["bd-a", "bd-parent", "bd-child"] {
storage
.create_issue(
&make_issue(id, id, Status::Open, 2, None, Utc::now(), None),
"tester",
)
.unwrap();
}
storage
.set_parent("bd-child", Some("bd-parent"), "tester")
.unwrap();
assert!(!storage.blocked_cache_marked_stale().unwrap());
for (status, issue_type, expected) in [
(
Status::Closed,
IssueType::Epic,
Some(r#"["bd-child:child-open"]"#),
),
(Status::Open, IssueType::Task, None),
] {
storage
.update_issues_atomically(
&[
(
"bd-a".to_string(),
IssueUpdate {
status: Some(status),
..IssueUpdate::default()
},
),
(
"bd-parent".to_string(),
IssueUpdate {
issue_type: Some(issue_type),
..IssueUpdate::default()
},
),
],
"tester",
)
.unwrap();
let rows = storage
.conn
.query("SELECT blocked_by FROM blocked_issues_cache WHERE issue_id = 'bd-parent'")
.unwrap();
assert_eq!(rows.len(), usize::from(expected.is_some()));
assert_eq!(
rows.first()
.and_then(|row| row.get(0))
.and_then(SqliteValue::as_text),
expected
);
assert!(!storage.blocked_cache_marked_stale().unwrap());
}
}
#[test]
fn test_update_issue_skip_cache_rebuild_marks_cache_stale_reads_compute_in_memory() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = make_issue(
"bd-blocker",
"Blocker",
Status::Open,
2,
None,
Utc::now(),
None,
);
let blocked = make_issue(
"bd-blocked",
"Blocked",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency(
&blocked.id,
&blocker.id,
DependencyType::Blocks.as_str(),
"tester",
)
.unwrap();
// Read operations compute blocked state in memory when the cache is
// stale, WITHOUT persisting (#216 — read ops must not write).
assert!(storage.is_blocked(&blocked.id).unwrap());
let close_update = IssueUpdate {
status: Some(Status::Closed),
skip_cache_rebuild: true,
..IssueUpdate::default()
};
storage
.update_issue(&blocker.id, &close_update, "tester")
.unwrap();
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"status updates with skip_cache_rebuild should leave a stale marker behind"
);
assert!(
!storage.is_blocked(&blocked.id).unwrap(),
"in-memory blocked computation should see the blocker is now closed"
);
// The stale marker should remain — read ops do not clear it.
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"cache should still be stale after read (reads must not write)"
);
}
#[test]
fn test_set_parent_skip_cache_rebuild_marks_cache_stale_reads_compute_in_memory() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let blocker = make_issue(
"bd-parent-blocker",
"Parent blocker",
Status::Open,
2,
None,
Utc::now(),
None,
);
let parent = make_issue(
"bd-parent",
"Parent",
Status::Open,
2,
None,
Utc::now(),
None,
);
let child = make_issue("bd-child", "Child", Status::Open, 2, None, Utc::now(), None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
storage
.add_dependency(
&parent.id,
&blocker.id,
DependencyType::Blocks.as_str(),
"tester",
)
.unwrap();
// Read operations compute blocked state in memory when the cache is
// stale, WITHOUT persisting (#216 — read ops must not write).
assert!(storage.is_blocked(&parent.id).unwrap());
storage
.set_parent_with_options(&child.id, Some(&parent.id), "tester", true)
.unwrap();
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"parent changes with skip_cache_rebuild should leave a stale marker behind"
);
assert!(
storage.is_blocked(&child.id).unwrap(),
"in-memory blocked computation should propagate parent blockers to the child"
);
// The stale marker should remain — read ops do not clear it.
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"cache should still be stale after read (reads must not write)"
);
}
#[test]
fn test_expand_blocked_cache_component_includes_parent_and_siblings() {
let children_by_parent = HashMap::from([
(
"bd-root".to_string(),
vec!["bd-parent".to_string(), "bd-aunt".to_string()],
),
(
"bd-parent".to_string(),
vec!["bd-parent.1".to_string(), "bd-parent.2".to_string()],
),
]);
let parents_by_child = SqliteStorage::build_parents_by_child(&children_by_parent);
let seed_ids = HashSet::from(["bd-parent.1".to_string()]);
let affected = SqliteStorage::expand_blocked_cache_component(
&seed_ids,
&children_by_parent,
&parents_by_child,
);
assert!(affected.contains("bd-parent.1"));
assert!(affected.contains("bd-parent"));
assert!(affected.contains("bd-parent.2"));
assert!(affected.contains("bd-root"));
assert!(affected.contains("bd-aunt"));
}
#[test]
fn test_incremental_blocked_cache_update_recomputes_entire_parent_child_component() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
// `bd-parent` is an Epic so that the "parent blocked by open
// children" rollup applies: the incremental update is specifically
// exercising the epic-child aggregation path, where removing a
// direct blocker on the epic should not unblock the epic as long
// as its children remain open. Non-epic parents intentionally do
// not carry that blocker (see `parent_child_transitive_blocking`
// in `tests/storage_blocked_cache.rs`), so the test would be
// testing semantics that no longer apply to plain tasks.
let mut parent = make_issue("bd-parent", "Parent", Status::Open, 2, None, now, None);
parent.issue_type = IssueType::Epic;
for issue in [
parent,
make_issue("bd-parent.1", "Child 1", Status::Open, 2, None, now, None),
make_issue("bd-parent.2", "Child 2", Status::Open, 2, None, now, None),
make_issue("bd-blocker", "Blocker", Status::Open, 2, None, now, None),
make_issue(
"bd-unrelated",
"Unrelated",
Status::Open,
2,
None,
now,
None,
),
make_issue(
"bd-unrelated-blocker",
"Unrelated blocker",
Status::Open,
2,
None,
now,
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_dependency("bd-parent.1", "bd-parent", "parent-child", "tester")
.unwrap();
storage
.add_dependency("bd-parent.2", "bd-parent", "parent-child", "tester")
.unwrap();
storage
.add_dependency("bd-parent", "bd-blocker", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-unrelated", "bd-unrelated-blocker", "blocks", "tester")
.unwrap();
storage.ensure_blocked_cache_fresh().unwrap();
storage.conn.execute("UPDATE blocked_issues_cache SET blocked_at = '2000-01-01' WHERE issue_id = 'bd-unrelated'").unwrap();
assert!(storage.is_blocked("bd-parent").unwrap());
assert!(storage.is_blocked("bd-parent.1").unwrap());
assert!(storage.is_blocked("bd-parent.2").unwrap());
assert!(storage.is_blocked("bd-unrelated").unwrap());
storage
.conn
.execute_with_params(
"DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
&[
SqliteValue::from("bd-parent"),
SqliteValue::from("bd-blocker"),
],
)
.unwrap();
let seed_ids = HashSet::from(["bd-parent.1".to_string()]);
SqliteStorage::incremental_blocked_cache_update(&storage.conn, &seed_ids).unwrap();
assert!(!storage.blocked_cache_marked_stale().unwrap());
assert_eq!(
persisted_blocked_cache(&storage),
vec![
(
"bd-parent".to_string(),
r#"["bd-parent.1:child-open","bd-parent.2:child-open"]"#.to_string()
),
(
"bd-unrelated".to_string(),
r#"["bd-unrelated-blocker:open"]"#.to_string()
),
]
);
let untouched = storage
.conn
.query_row(
"SELECT blocked_at FROM blocked_issues_cache WHERE issue_id = 'bd-unrelated'",
)
.unwrap();
assert_eq!(
untouched.get(0).and_then(SqliteValue::as_text),
Some("2000-01-01")
);
let parent_blockers = storage.get_blockers("bd-parent").unwrap();
assert_eq!(
parent_blockers,
vec!["bd-parent.1".to_string(), "bd-parent.2".to_string()]
);
assert!(storage.get_blockers("bd-parent.1").unwrap().is_empty());
assert!(storage.get_blockers("bd-parent.2").unwrap().is_empty());
assert_eq!(
storage.get_blockers("bd-unrelated").unwrap(),
vec!["bd-unrelated-blocker".to_string()]
);
}
#[test]
fn test_get_start_blockers_ignores_child_open_but_keeps_real_blockers() {
// #315: an epic that is "blocked" only by its own still-open children
// must remain claimable / startable (the child-open rollup is a
// close-ordering constraint, not a real dependency). A genuine `blocks`
// edge must still prevent starting, and the child-open marker must be
// filtered out of the reported start-blockers.
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc::now();
let mut epic = make_issue("bd-epic", "Epic", Status::Open, 2, None, now, None);
epic.issue_type = IssueType::Epic;
let mut blocked_epic = make_issue(
"bd-epic-blocked",
"Blocked epic",
Status::Open,
2,
None,
now,
None,
);
blocked_epic.issue_type = IssueType::Epic;
for issue in [
epic,
make_issue("bd-epic.1", "Child", Status::Open, 2, None, now, None),
blocked_epic,
make_issue(
"bd-epic-blocked.1",
"Child of blocked epic",
Status::Open,
2,
None,
now,
None,
),
make_issue(
"bd-real-blocker",
"Real blocker",
Status::Open,
2,
None,
now,
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
storage
.add_dependency("bd-epic.1", "bd-epic", "parent-child", "tester")
.unwrap();
storage
.add_dependency(
"bd-epic-blocked.1",
"bd-epic-blocked",
"parent-child",
"tester",
)
.unwrap();
storage
.add_dependency("bd-epic-blocked", "bd-real-blocker", "blocks", "tester")
.unwrap();
// Both epics are "blocked" in the close-ordering sense (open children).
assert!(storage.is_blocked("bd-epic").unwrap());
assert!(storage.is_blocked("bd-epic-blocked").unwrap());
// An epic blocked ONLY by open children has no start-blockers: claimable.
assert!(
storage.get_start_blockers("bd-epic").unwrap().is_empty(),
"epic with only open children must be startable (#315)"
);
// The close-ordering view (get_blockers) is unchanged — still rolls up.
assert_eq!(
storage.get_blockers("bd-epic").unwrap(),
vec!["bd-epic.1".to_string()]
);
// An epic with a real `blocks` dependency is still start-blocked, and
// the child-open marker is filtered out of the reported blockers.
assert_eq!(
storage.get_start_blockers("bd-epic-blocked").unwrap(),
vec!["bd-real-blocker".to_string()],
"real blocks edge must still block starting; child-open filtered (#315)"
);
}
#[test]
fn test_get_start_blockers_ignores_inherited_parent_blocked_marker() {
// #357 (start-path counterpart of #355): a child whose ONLY blocker is
// the inherited `<parent>:parent-blocked` rollup — i.e. its parent epic
// is itself blocked, but the child has no real prerequisite of its own —
// must be claimable / startable. `parent-child` is hierarchy, not a
// prerequisite edge from the parent to the child, and the actionable
// children of a blocked epic are frequently exactly the work that
// unblocks the epic. A child with a DIRECT real blocker must still
// reject, so this strips only the propagated marker, not real edges.
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc::now();
let mut parent = make_issue("bd-parent", "Parent epic", Status::Open, 2, None, now, None);
parent.issue_type = IssueType::Epic;
for issue in [
parent,
make_issue("bd-child", "Child task", Status::Open, 2, None, now, None),
make_issue(
"bd-child-direct",
"Child with its own blocker",
Status::Open,
2,
None,
now,
None,
),
make_issue(
"bd-blocker",
"External blocker",
Status::Open,
2,
None,
now,
None,
),
make_issue(
"bd-direct-blocker",
"Direct blocker of bd-child-direct",
Status::Open,
2,
None,
now,
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
// The parent epic is genuinely blocked by an open prerequisite.
storage
.add_dependency("bd-parent", "bd-blocker", "blocks", "tester")
.unwrap();
// Both children belong to the parent via hierarchy (parent-child).
storage
.add_dependency("bd-child", "bd-parent", "parent-child", "tester")
.unwrap();
storage
.add_dependency("bd-child-direct", "bd-parent", "parent-child", "tester")
.unwrap();
// bd-child-direct ALSO has a real prerequisite of its own.
storage
.add_dependency("bd-child-direct", "bd-direct-blocker", "blocks", "tester")
.unwrap();
// The blocked parent propagates a `:parent-blocked` marker onto its
// children — confirm the child carries it as its only (non-direct)
// blocker before asserting the start gate ignores it.
let child_blockers = storage.get_blockers("bd-child").unwrap();
assert!(
child_blockers
.iter()
.any(|b| b == "bd-parent" || b == "bd-parent:parent-blocked"),
"child of a blocked epic should inherit the parent-blocked rollup: {child_blockers:?}"
);
// #357: a child whose only blocker is the inherited parent-blocked
// marker has no real start-blockers — it must be claimable / startable.
assert!(
storage.get_start_blockers("bd-child").unwrap().is_empty(),
"child blocked only by inherited :parent-blocked must be startable (#357)"
);
// A child with a DIRECT real `blocks` edge is still start-blocked; only
// the inherited parent-blocked marker is filtered, never the real edge.
assert_eq!(
storage.get_start_blockers("bd-child-direct").unwrap(),
vec!["bd-direct-blocker".to_string()],
"a real direct blocker must still prevent starting (#357 strips only the rollup)"
);
// The close path (already fixed by #355) remains correct: the child
// with only the inherited marker is closable too.
assert!(
storage.get_close_blockers("bd-child").unwrap().is_empty(),
"child blocked only by inherited :parent-blocked must be closable (#355)"
);
}
#[test]
fn test_rebuild_blocked_cache_impl_recreates_table_and_repopulates_entries() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc::now();
let blocker = make_issue("bd-reset-b1", "Blocker", Status::Open, 2, None, now, None);
let blocked = make_issue("bd-reset-c1", "Blocked", Status::Open, 2, None, now, None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency(&blocked.id, &blocker.id, "blocks", "tester")
.unwrap();
storage
.conn
.execute("DELETE FROM blocked_issues_cache")
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO blocked_issues_cache (issue_id, blocked_by, blocked_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
&[
SqliteValue::from(blocked.id.as_str()),
SqliteValue::from("[\"bd-old:open\"]"),
],
)
.unwrap();
let rebuilt = SqliteStorage::rebuild_blocked_cache_impl(&storage.conn).unwrap();
assert_eq!(rebuilt, 1);
assert_eq!(storage.get_blockers(&blocked.id).unwrap(), vec![blocker.id]);
}
#[test]
fn test_get_blocked_ids_and_is_blocked_fall_back_when_cache_table_missing() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 2, None, Utc::now(), None);
let blocked = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute("DROP TABLE blocked_issues_cache")
.unwrap();
let blocked_ids = storage.get_blocked_ids().unwrap();
assert!(blocked_ids.contains("bd-c1"));
assert!(storage.is_blocked("bd-c1").unwrap());
}
#[test]
fn test_get_ready_issues_fall_back_when_cache_table_missing() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, Utc::now(), None);
let blocked = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
let ready = make_issue(
"bd-r1",
"Ready issue",
Status::Open,
3,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage.create_issue(&ready, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute("DROP TABLE blocked_issues_cache")
.unwrap();
let ready_ids: HashSet<_> = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Priority)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert!(!ready_ids.contains("bd-c1"));
assert!(ready_ids.contains("bd-b1"));
assert!(ready_ids.contains("bd-r1"));
}
#[test]
fn test_get_ready_issues_for_command_output_matches_full_ready_output() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 20, 12, 0, 0).unwrap();
let mut ready = make_issue(
"bd-ready-detailed",
"Detailed ready issue",
Status::Open,
1,
Some("alice"),
created_at,
None,
);
ready.description = Some("Description".to_string());
ready.design = Some("Should not be loaded for ready output".to_string());
ready.acceptance_criteria = Some("AC".to_string());
ready.notes = Some("Notes".to_string());
ready.owner = Some("product".to_string());
ready.estimated_minutes = Some(45);
ready.created_by = Some("agent".to_string());
ready.updated_at = created_at + chrono::Duration::minutes(5);
ready.external_ref = Some("jira-123".to_string());
ready.source_system = Some("jira".to_string());
ready.source_repo = Some("proj".to_string());
ready.sender = Some("cli".to_string());
let blocker = make_issue(
"bd-blocker-detailed",
"Blocker",
Status::Open,
0,
None,
created_at + chrono::Duration::minutes(1),
None,
);
let blocked = make_issue(
"bd-blocked-detailed",
"Blocked",
Status::Open,
2,
None,
created_at + chrono::Duration::minutes(2),
None,
);
storage.create_issue(&ready, "tester").unwrap();
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency(
"bd-blocked-detailed",
"bd-blocker-detailed",
"blocks",
"tester",
)
.unwrap();
let filters = ReadyFilters::default();
let full: Vec<_> = storage
.get_ready_issues(&filters, ReadySortPolicy::Priority)
.unwrap()
.into_iter()
.map(ReadyIssue::from)
.collect();
let projected: Vec<_> = storage
.get_ready_issues_for_command_output(&filters, ReadySortPolicy::Priority)
.unwrap()
.into_iter()
.map(ReadyIssue::from)
.collect();
assert_eq!(projected, full);
}
#[test]
fn test_get_ready_summary_issues_for_command_output_matches_full_text_fields() {
let storage = ready_summary_projection_fixture();
let filters = ReadyFilters::default();
let full = storage
.get_ready_issues(&filters, ReadySortPolicy::Priority)
.unwrap()
.into_iter()
.map(ready_text_fields)
.collect::<Vec<_>>();
let projected_raw = storage
.get_ready_summary_issues_for_command_output(&filters, ReadySortPolicy::Priority)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-ready-summary")
.unwrap();
assert!(projected_issue.description.is_none());
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.assignee.is_none());
assert!(projected_issue.owner.is_none());
assert!(projected_issue.estimated_minutes.is_none());
assert!(projected_issue.created_by.is_none());
let projected = projected_raw
.into_iter()
.map(ready_text_fields)
.collect::<Vec<_>>();
assert_eq!(projected, full);
}
#[test]
fn test_limited_ready_hybrid_hydrates_command_projection_after_summary_window() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 20, 12, 0, 0).unwrap();
let mut first = make_issue(
"bd-ready-window-first",
"First visible ready issue",
Status::Open,
1,
Some("alice"),
created_at,
None,
);
first.description = Some("Visible description".to_string());
first.acceptance_criteria = Some("Visible acceptance criteria".to_string());
first.notes = Some("Visible notes".to_string());
first.owner = Some("product".to_string());
first.estimated_minutes = Some(30);
first.created_by = Some("agent".to_string());
first.updated_at = created_at + chrono::Duration::minutes(5);
let second = make_issue(
"bd-ready-window-second",
"Second ready issue",
Status::Open,
1,
None,
created_at + chrono::Duration::minutes(1),
None,
);
storage.create_issue(&first, "tester").unwrap();
storage.create_issue(&second, "tester").unwrap();
let filters = ReadyFilters {
limit: Some(1),
..ReadyFilters::default()
};
let command = storage
.get_ready_issues_for_command_output(&filters, ReadySortPolicy::Hybrid)
.unwrap();
assert_eq!(command.len(), 1);
assert_eq!(command[0].id, "bd-ready-window-first");
assert_eq!(
command[0].description.as_deref(),
Some("Visible description")
);
assert_eq!(
command[0].acceptance_criteria.as_deref(),
Some("Visible acceptance criteria")
);
assert_eq!(command[0].notes.as_deref(), Some("Visible notes"));
assert_eq!(command[0].assignee.as_deref(), Some("alice"));
assert_eq!(command[0].owner.as_deref(), Some("product"));
assert_eq!(command[0].estimated_minutes, Some(30));
assert_eq!(command[0].created_by.as_deref(), Some("agent"));
let summary = storage
.get_ready_summary_issues_for_command_output(&filters, ReadySortPolicy::Hybrid)
.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].id, "bd-ready-window-first");
assert!(summary[0].description.is_none());
assert!(summary[0].acceptance_criteria.is_none());
assert!(summary[0].notes.is_none());
assert!(summary[0].assignee.is_none());
}
#[test]
fn test_limited_ready_hybrid_summary_window_skips_blocked_candidates() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 20, 12, 0, 0).unwrap();
let blocker = make_issue(
"bd-ready-window-blocker",
"Window blocker",
Status::Open,
0,
None,
created_at,
None,
);
let blocked_first = make_issue(
"bd-ready-window-blocked-first",
"Blocked first ready candidate",
Status::Open,
0,
None,
created_at + chrono::Duration::seconds(1),
None,
);
let unblocked_second = make_issue(
"bd-ready-window-unblocked-second",
"Unblocked second ready candidate",
Status::Open,
0,
None,
created_at + chrono::Duration::seconds(2),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked_first, "tester").unwrap();
storage.create_issue(&unblocked_second, "tester").unwrap();
storage
.add_dependency(
"bd-ready-window-blocked-first",
"bd-ready-window-blocker",
"blocks",
"tester",
)
.unwrap();
let filters = ReadyFilters {
limit: Some(1),
..ReadyFilters::default()
};
let issues = storage
.get_ready_summary_issues_for_command_output(&filters, ReadySortPolicy::Hybrid)
.unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "bd-ready-window-blocker");
}
#[test]
fn test_limited_ready_hybrid_falls_back_when_high_bucket_is_short() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 20, 12, 0, 0).unwrap();
let high = make_issue(
"bd-ready-window-high",
"High priority ready issue",
Status::Open,
1,
None,
created_at,
None,
);
let medium = make_issue(
"bd-ready-window-medium",
"Medium priority fallback issue",
Status::Open,
2,
None,
created_at + chrono::Duration::minutes(1),
None,
);
storage.create_issue(&high, "tester").unwrap();
storage.create_issue(&medium, "tester").unwrap();
let filters = ReadyFilters {
limit: Some(2),
..ReadyFilters::default()
};
let issues = storage
.get_ready_issues_for_command_output(&filters, ReadySortPolicy::Hybrid)
.unwrap();
let ids = issues
.iter()
.map(|issue| issue.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["bd-ready-window-high", "bd-ready-window-medium"]);
}
#[test]
fn test_get_blocked_issues_for_command_output_matches_full_blocked_output() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 4, 1, 9, 0, 0).unwrap();
let blocker = make_issue(
"bd-blocker-detailed",
"Blocker",
Status::Open,
0,
None,
created_at,
None,
);
let mut blocked = make_issue(
"bd-blocked-detailed",
"Blocked detailed issue",
Status::Open,
2,
None,
created_at + chrono::Duration::minutes(1),
None,
);
let large_unused = "unused overflow payload ".repeat(256);
blocked.description = Some("Visible blocked description".to_string());
blocked.design = Some(large_unused.clone());
blocked.acceptance_criteria = Some(large_unused.clone());
blocked.notes = Some(large_unused);
blocked.created_by = Some("agent".to_string());
blocked.updated_at = created_at + chrono::Duration::minutes(5);
blocked.source_repo = Some("repo".to_string());
blocked.sender = Some("cli".to_string());
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency(
"bd-blocked-detailed",
"bd-blocker-detailed",
"blocks",
"tester",
)
.unwrap();
let full: Vec<_> = storage
.get_blocked_issues()
.unwrap()
.into_iter()
.map(blocked_issue_output_for_test)
.collect();
let projected_raw = storage.get_blocked_issues_for_command_output().unwrap();
let projected_issue = &projected_raw
.iter()
.find(|(issue, _)| issue.id == "bd-blocked-detailed")
.unwrap()
.0;
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.source_repo.is_none());
assert!(projected_issue.sender.is_none());
let projected: Vec<_> = projected_raw
.into_iter()
.map(blocked_issue_output_for_test)
.collect();
assert_eq!(
serde_json::to_value(projected).unwrap(),
serde_json::to_value(full).unwrap()
);
}
#[test]
fn test_get_blocked_issues_for_command_output_falls_back_when_cache_table_missing() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, created_at, None);
let mut blocked = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
created_at + chrono::Duration::minutes(1),
None,
);
blocked.description = Some("Visible description".to_string());
blocked.design = Some("unused design".repeat(512));
let ready = make_issue(
"bd-r1",
"Ready issue",
Status::Open,
3,
None,
created_at + chrono::Duration::minutes(2),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage.create_issue(&ready, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute("DROP TABLE blocked_issues_cache")
.unwrap();
let full: Vec<_> = storage
.get_blocked_issues()
.unwrap()
.into_iter()
.map(blocked_issue_output_for_test)
.collect();
let projected_raw = storage.get_blocked_issues_for_command_output().unwrap();
let projected_issue = &projected_raw
.iter()
.find(|(issue, _)| issue.id == "bd-c1")
.unwrap()
.0;
assert!(projected_issue.design.is_none());
let projected: Vec<_> = projected_raw
.into_iter()
.map(blocked_issue_output_for_test)
.collect();
assert_eq!(
serde_json::to_value(projected).unwrap(),
serde_json::to_value(full).unwrap()
);
}
#[test]
fn test_list_stale_issues_for_command_output_matches_full_stale_output() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 4, 10, 12, 0, 0).unwrap();
let old_at = now - chrono::Duration::days(45);
let fresh_at = now - chrono::Duration::days(1);
let mut stale_issue = make_issue(
"bd-stale-detailed",
"Detailed stale issue",
Status::Open,
1,
Some("alice"),
old_at,
None,
);
stale_issue.description = Some("Should not be loaded".to_string());
stale_issue.design = Some("unused design".repeat(512));
stale_issue.acceptance_criteria = Some("unused ac".repeat(512));
stale_issue.notes = Some("unused notes".repeat(512));
stale_issue.owner = Some("owner".to_string());
stale_issue.created_by = Some("creator".to_string());
stale_issue.source_repo = Some("repo".to_string());
stale_issue.sender = Some("cli".to_string());
let fresh_issue = make_issue(
"bd-fresh",
"Fresh issue",
Status::Open,
2,
Some("bob"),
fresh_at,
None,
);
storage.create_issue(&stale_issue, "tester").unwrap();
storage.create_issue(&fresh_issue, "tester").unwrap();
let filters = ListFilters {
include_deferred: true,
updated_before: Some(now - chrono::Duration::days(30)),
sort: Some("updated_at".to_string()),
reverse: true,
..ListFilters::default()
};
let full: Vec<_> = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(StaleIssue::from)
.collect();
let projected_raw = storage
.list_stale_issues_for_command_output(&filters)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-stale-detailed")
.unwrap();
assert!(projected_issue.description.is_none());
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.source_repo.is_none());
assert!(projected_issue.sender.is_none());
let projected: Vec<_> = projected_raw.into_iter().map(StaleIssue::from).collect();
assert_eq!(
serde_json::to_value(projected).unwrap(),
serde_json::to_value(full).unwrap()
);
}
#[test]
fn test_list_orphan_candidate_issues_for_command_output_matches_full_candidate_fields() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 4, 11, 12, 0, 0).unwrap();
let mut open_issue = make_issue(
"bd-orphan-open",
"Open orphan candidate",
Status::Open,
1,
None,
now,
None,
);
open_issue.description = Some("Should not be loaded".to_string());
open_issue.design = Some("unused design".repeat(512));
open_issue.acceptance_criteria = Some("unused ac".repeat(512));
open_issue.notes = Some("unused notes".repeat(512));
open_issue.owner = Some("owner".to_string());
open_issue.sender = Some("cli".to_string());
let in_progress_issue = make_issue(
"bd-orphan-progress",
"In-progress orphan candidate",
Status::InProgress,
2,
None,
now - chrono::Duration::minutes(1),
None,
);
let mut closed_issue = make_issue(
"bd-orphan-closed",
"Closed non-candidate",
Status::Closed,
3,
None,
now - chrono::Duration::minutes(2),
None,
);
closed_issue.closed_at = Some(now);
storage.create_issue(&open_issue, "tester").unwrap();
storage.create_issue(&in_progress_issue, "tester").unwrap();
storage.create_issue(&closed_issue, "tester").unwrap();
let filters = ListFilters {
statuses: Some(vec![Status::Open, Status::InProgress]),
..ListFilters::default()
};
let full = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
let projected_raw = storage
.list_orphan_candidate_issues_for_command_output(&filters)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-orphan-open")
.unwrap();
assert!(projected_issue.description.is_none());
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.owner.is_none());
assert!(projected_issue.sender.is_none());
let projected = projected_raw
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
assert_eq!(projected, full);
}
#[test]
fn test_list_graph_issues_for_command_output_matches_full_graph_fields() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 4, 12, 12, 0, 0).unwrap();
let mut open_issue = make_issue(
"bd-graph-open",
"Open graph node",
Status::Open,
1,
None,
now,
None,
);
open_issue.description = Some("Should not be loaded".to_string());
open_issue.design = Some("unused design".repeat(512));
open_issue.acceptance_criteria = Some("unused ac".repeat(512));
open_issue.notes = Some("unused notes".repeat(512));
open_issue.owner = Some("owner".to_string());
open_issue.sender = Some("cli".to_string());
let deferred_issue = make_issue(
"bd-graph-deferred",
"Deferred graph node",
Status::Deferred,
2,
None,
now - chrono::Duration::minutes(1),
None,
);
let mut closed_issue = make_issue(
"bd-graph-closed",
"Closed non-node",
Status::Closed,
3,
None,
now - chrono::Duration::minutes(2),
None,
);
closed_issue.closed_at = Some(now);
storage.create_issue(&open_issue, "tester").unwrap();
storage.create_issue(&deferred_issue, "tester").unwrap();
storage.create_issue(&closed_issue, "tester").unwrap();
let filters = ListFilters {
include_deferred: true,
..ListFilters::default()
};
let full = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
let projected_raw = storage
.list_graph_issues_for_command_output(&filters)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-graph-open")
.unwrap();
assert!(projected_issue.description.is_none());
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.owner.is_none());
assert!(projected_issue.sender.is_none());
let projected = projected_raw
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
assert_eq!(projected, full);
}
#[test]
fn test_list_issues_default_visible_limited_page_matches_sql_order() {
let mut storage = SqliteStorage::open_memory().unwrap();
let day_1 = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
let day_2 = Utc.with_ymd_and_hms(2026, 4, 14, 12, 0, 0).unwrap();
let day_3 = Utc.with_ymd_and_hms(2026, 4, 15, 12, 0, 0).unwrap();
for issue in [
make_issue("bd-p1-old", "P1 old", Status::Open, 1, None, day_1, None),
make_issue("bd-p0-old", "P0 old", Status::Open, 0, None, day_1, None),
make_issue("bd-p0-a", "P0 tie A", Status::Open, 0, None, day_2, None),
make_issue("bd-p0-b", "P0 tie B", Status::Open, 0, None, day_2, None),
make_issue(
"bd-deferred-new",
"Deferred new",
Status::Deferred,
0,
None,
day_3,
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
let mut closed = make_issue("bd-closed", "Closed", Status::Closed, 0, None, day_3, None);
closed.closed_at = Some(day_3);
storage.create_issue(&closed, "tester").unwrap();
let mut template = make_issue(
"bd-template",
"Template",
Status::Open,
0,
None,
day_3,
None,
);
template.is_template = true;
storage.create_issue(&template, "tester").unwrap();
let fast = storage
.list_issues(&ListFilters {
include_deferred: true,
limit: Some(5),
offset: Some(0),
..ListFilters::default()
})
.unwrap();
let baseline = storage
.list_issues(&ListFilters {
include_deferred: true,
limit: Some(5),
offset: Some(0),
sort: Some("priority".to_string()),
..ListFilters::default()
})
.unwrap();
let fast_ids = fast
.iter()
.map(|issue| issue.id.as_str())
.collect::<Vec<_>>();
let baseline_ids = baseline
.iter()
.map(|issue| issue.id.as_str())
.collect::<Vec<_>>();
assert_eq!(fast_ids, baseline_ids);
assert_eq!(
fast_ids,
vec![
"bd-deferred-new",
"bd-p0-a",
"bd-p0-b",
"bd-p0-old",
"bd-p1-old"
]
);
}
#[test]
fn test_list_text_issues_for_command_output_matches_full_summary_fields() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
let mut open_issue = make_issue(
"bd-list-open",
"Open text row",
Status::Open,
1,
None,
now,
None,
);
open_issue.description = Some("Should not be loaded".to_string());
open_issue.design = Some("unused design".repeat(512));
open_issue.acceptance_criteria = Some("unused ac".repeat(512));
open_issue.notes = Some("unused notes".repeat(512));
open_issue.owner = Some("owner".to_string());
open_issue.sender = Some("cli".to_string());
let deferred_issue = make_issue(
"bd-list-deferred",
"Deferred text row",
Status::Deferred,
2,
None,
now - chrono::Duration::minutes(1),
None,
);
let mut closed_issue = make_issue(
"bd-list-closed",
"Closed non-row",
Status::Closed,
3,
None,
now - chrono::Duration::minutes(2),
None,
);
closed_issue.closed_at = Some(now);
storage.create_issue(&open_issue, "tester").unwrap();
storage.create_issue(&deferred_issue, "tester").unwrap();
storage.create_issue(&closed_issue, "tester").unwrap();
let filters = ListFilters {
include_deferred: true,
limit: Some(0),
offset: Some(0),
..ListFilters::default()
};
let full = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(|issue| {
(
issue.id,
issue.title,
issue.status,
issue.priority,
issue.issue_type,
)
})
.collect::<Vec<_>>();
let projected_raw = storage
.list_text_issues_for_command_output(&filters)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-list-open")
.unwrap();
assert!(projected_issue.description.is_none());
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.owner.is_none());
assert!(projected_issue.sender.is_none());
let projected = projected_raw
.into_iter()
.map(|issue| {
(
issue.id,
issue.title,
issue.status,
issue.priority,
issue.issue_type,
)
})
.collect::<Vec<_>>();
assert_eq!(projected, full);
}
#[test]
fn test_list_text_issues_for_command_output_supports_limit_offset() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 4, 13, 12, 0, 0).unwrap();
let mut oldest_high = make_issue(
"bd-list-high-old",
"High old",
Status::Open,
1,
None,
now - chrono::Duration::minutes(2),
None,
);
oldest_high.description = Some("Should not be loaded".repeat(256));
let newest_high = make_issue(
"bd-list-high-new",
"High new",
Status::Open,
1,
None,
now,
None,
);
let normal = make_issue(
"bd-list-normal",
"Normal",
Status::Open,
2,
None,
now - chrono::Duration::minutes(1),
None,
);
storage.create_issue(&oldest_high, "tester").unwrap();
storage.create_issue(&newest_high, "tester").unwrap();
storage.create_issue(&normal, "tester").unwrap();
let filters = ListFilters {
include_deferred: true,
limit: Some(2),
offset: Some(1),
..ListFilters::default()
};
let full = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
let projected_raw = storage
.list_text_issues_for_command_output(&filters)
.unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-list-high-old")
.unwrap();
assert!(projected_issue.description.is_none());
let projected = projected_raw
.into_iter()
.map(|issue| (issue.id, issue.title, issue.status, issue.priority))
.collect::<Vec<_>>();
assert_eq!(
projected,
vec![
(
"bd-list-high-old".to_string(),
"High old".to_string(),
Status::Open,
Priority(1),
),
(
"bd-list-normal".to_string(),
"Normal".to_string(),
Status::Open,
Priority(2),
),
]
);
assert_eq!(projected, full);
}
#[test]
fn test_get_ready_issues_for_command_output_falls_back_when_cache_table_missing() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, Utc::now(), None);
let blocked = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
let ready = make_issue(
"bd-r1",
"Ready issue",
Status::Open,
3,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage.create_issue(&ready, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute("DROP TABLE blocked_issues_cache")
.unwrap();
let full: Vec<_> = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Priority)
.unwrap()
.into_iter()
.map(ReadyIssue::from)
.collect();
let projected: Vec<_> = storage
.get_ready_issues_for_command_output(
&ReadyFilters::default(),
ReadySortPolicy::Priority,
)
.unwrap()
.into_iter()
.map(ReadyIssue::from)
.collect();
assert_eq!(projected, full);
}
#[test]
fn test_get_blockers_fall_back_on_malformed_cache_json() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 2, None, Utc::now(), None);
let issue = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&issue, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute_with_params(
"UPDATE blocked_issues_cache SET blocked_by = ? WHERE issue_id = ?",
&[SqliteValue::from("not-json"), SqliteValue::from("bd-c1")],
)
.unwrap();
assert_eq!(
storage.get_blockers("bd-c1").unwrap(),
vec!["bd-b1".to_string()]
);
}
#[test]
fn test_get_blocked_issues_fall_back_on_malformed_cache_json() {
let mut storage = SqliteStorage::open_memory().unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 1, None, Utc::now(), None);
let blocked = make_issue(
"bd-c1",
"Blocked issue",
Status::Open,
2,
None,
Utc::now(),
None,
);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.conn
.execute_with_params(
"UPDATE blocked_issues_cache SET blocked_by = ? WHERE issue_id = ?",
&[SqliteValue::from("not-json"), SqliteValue::from("bd-c1")],
)
.unwrap();
let blocked_issues = storage.get_blocked_issues().unwrap();
assert_eq!(blocked_issues.len(), 1);
assert_eq!(blocked_issues[0].0.id, "bd-c1");
assert!(
blocked_issues[0]
.1
.iter()
.any(|blocker_ref| blocker_ref.starts_with("bd-b1:")),
"fallback should preserve blocker metadata, got: {:?}",
blocked_issues[0].1
);
}
#[test]
fn test_blocker_helpers_ignore_non_blocking_related_edges() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 3, 3, 0, 0, 0).unwrap();
let blocker = make_issue("bd-b1", "Blocker", Status::Open, 2, None, t1, None);
let blocked = make_issue("bd-c1", "Blocked", Status::Open, 2, None, t1, None);
let parent = make_issue("bd-p1", "Parent", Status::Open, 2, None, t1, None);
let child = make_issue("bd-p1.1", "Child", Status::Open, 2, None, t1, None);
let related = make_issue("bd-r1", "Related", Status::Open, 2, None, t1, None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&blocked, "tester").unwrap();
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&child, "tester").unwrap();
storage.create_issue(&related, "tester").unwrap();
storage
.add_dependency("bd-c1", "bd-b1", "blocks", "tester")
.unwrap();
storage
.add_dependency("bd-p1.1", "bd-p1", "parent-child", "tester")
.unwrap();
storage
.add_dependency("bd-r1", "bd-b1", "related", "tester")
.unwrap();
let blocker_ids = storage.get_blocker_ids("bd-c1").unwrap();
assert_eq!(blocker_ids, vec!["bd-b1"]);
let parent_blockers = storage.get_blocker_ids("bd-p1").unwrap();
assert_eq!(parent_blockers, vec!["bd-p1.1"]);
let related_blockers = storage.get_blocker_ids("bd-r1").unwrap();
assert!(
related_blockers.is_empty(),
"non-blocking related edges should not be reported as blockers"
);
let blocked_issue_ids = storage.get_blocked_issue_ids("bd-b1").unwrap();
assert_eq!(blocked_issue_ids, vec!["bd-c1"]);
let child_blocked_issue_ids = storage.get_blocked_issue_ids("bd-p1.1").unwrap();
assert_eq!(child_blocked_issue_ids, vec!["bd-p1"]);
}
#[test]
fn test_update_issue_recomputes_hash() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut issue = make_issue(
"bd-h1",
"Old Title",
Status::Open,
2,
None,
Utc::now(),
None,
);
issue.content_hash = Some(issue.compute_content_hash());
storage.create_issue(&issue, "tester").unwrap();
// Get initial hash
let initial = storage.get_issue("bd-h1").unwrap().unwrap();
let initial_hash = initial.content_hash.unwrap();
// Update title
let update = IssueUpdate {
title: Some("New Title".to_string()),
..IssueUpdate::default()
};
storage.update_issue("bd-h1", &update, "tester").unwrap();
// Check new hash
let updated = storage.get_issue("bd-h1").unwrap().unwrap();
let updated_hash = updated.content_hash.unwrap();
assert_ne!(
initial_hash, updated_hash,
"Hash should change when title changes"
);
}
#[test]
fn test_delete_config() {
let mut storage = SqliteStorage::open_memory().unwrap();
// Set a config value
storage.set_config("test_key", "test_value").unwrap();
assert_eq!(
storage.get_config("test_key").unwrap(),
Some("test_value".to_string())
);
// Delete it
let deleted = storage.delete_config("test_key").unwrap();
assert!(deleted, "Should return true when key existed");
assert_eq!(storage.get_config("test_key").unwrap(), None);
// Delete non-existent key
let deleted_again = storage.delete_config("nonexistent").unwrap();
assert!(!deleted_again, "Should return false when key doesn't exist");
}
#[test]
fn test_set_config_normalizes_issue_prefix() {
let mut storage = SqliteStorage::open_memory().unwrap();
storage
.set_config("issue_prefix", " Project-Name! ")
.unwrap();
assert_eq!(
storage.get_config("issue_prefix").unwrap(),
Some("project-name".to_string())
);
}
#[test]
fn test_open_creates_database() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("new_db.db");
assert!(!db_path.exists(), "Database should not exist yet");
let _storage = SqliteStorage::open(&db_path).unwrap();
assert!(db_path.exists(), "Database file should be created");
}
#[test]
fn test_database_header_user_version_reads_file_header_value() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("header_user_version.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))
.unwrap();
conn.close().unwrap();
assert_eq!(
database_header_user_version(&db_path),
Some(u32::try_from(CURRENT_SCHEMA_VERSION).unwrap())
);
}
#[cfg(unix)]
#[test]
fn test_schema_preflight_refuses_symlinked_database_without_mutating_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let target = temp.path().join("header_target.db");
let alias = temp.path().join("header_alias.db");
let storage = SqliteStorage::open(&target).unwrap();
drop(storage);
symlink(&target, &alias).unwrap();
let family_before = directory_bytes_and_modes(temp.path());
let error = SqliteStorage::open_with_timeout(&alias, Some(50))
.expect_err("schema preflight must never follow a database symlink");
assert!(
error.to_string().contains("not a regular file"),
"unexpected database symlink refusal: {error}"
);
assert_eq!(
directory_bytes_and_modes(temp.path()),
family_before,
"database symlink refusal must leave the target family byte and mode neutral"
);
}
#[test]
fn test_open_with_timeout_does_not_require_write_lock_when_schema_current() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("lock_read_open.db");
let _ = SqliteStorage::open(&db_path).unwrap();
let lock_conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
lock_conn.execute("BEGIN IMMEDIATE").unwrap();
let opened = SqliteStorage::open_with_timeout(&db_path, Some(50));
assert!(
opened.is_ok(),
"opening an existing DB should succeed for read paths under a concurrent write lock"
);
lock_conn.execute("COMMIT").unwrap();
}
#[test]
fn test_open_with_timeout_refuses_future_schema_without_downgrade() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("future_schema.db");
let future_version = u32::try_from(CURRENT_SCHEMA_VERSION)
.unwrap()
.checked_add(1)
.unwrap();
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute(&format!("PRAGMA user_version = {future_version}"))
.unwrap();
}
assert_eq!(
effective_database_user_version(&db_path).unwrap(),
Some(future_version),
"the fixture must expose the future version through the effective connection state"
);
let database_bytes_before = fs::read(&db_path).unwrap();
let error = SqliteStorage::open_with_timeout(&db_path, Some(50))
.expect_err("ordinary open must reject an unknown future schema");
assert!(
error
.to_string()
.contains("newer than this br binary supports")
&& error
.to_string()
.contains("refusing to modify or downgrade"),
"unexpected future-schema error: {error}"
);
assert_eq!(
effective_database_user_version(&db_path).unwrap(),
Some(future_version),
"a rejected open must not stamp the future database back to the current version"
);
assert_eq!(
fs::read(&db_path).unwrap(),
database_bytes_before,
"a rejected open must leave the main database bytes unchanged"
);
}
#[cfg(unix)]
#[test]
fn test_wal_only_future_schema_is_refused_by_byte_neutral_preflight() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("wal_only_future_schema.db");
let current_version = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap();
let future_version = current_version.checked_add(1).unwrap();
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
assert_eq!(
database_header_user_version(&db_path),
Some(current_version),
"the settled main header must start current"
);
let writer = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
writer.execute("PRAGMA wal_autocheckpoint=0").unwrap();
writer
.execute(&format!("PRAGMA user_version = {future_version}"))
.unwrap();
assert_eq!(connection_user_version(&writer), Some(future_version));
assert_eq!(
database_header_user_version(&db_path),
Some(current_version),
"the fixture must keep the future version out of the main header"
);
assert_eq!(
sqlite_wal_schema_preflight(&db_path)
.unwrap()
.committed_user_version,
Some(future_version),
"the fixture must carry the future version in WAL page-one frames"
);
let family_before = directory_bytes_and_modes(temp.path());
let error = SqliteStorage::open_with_timeout(&db_path, Some(50))
.expect_err("a WAL-only future version must be refused before writable open");
assert!(
error
.to_string()
.contains("newer than this br binary supports"),
"unexpected WAL-only future error: {error}"
);
assert_eq!(
directory_bytes_and_modes(temp.path()),
family_before,
"future-version preflight must not create, chmod, or rewrite any family member"
);
assert!(
SqliteStorage::open_current_for_reconcile(&db_path, Some(50))
.expect("WAL-only future schema must be classified without an engine open")
.is_none(),
"reviewed reconciliation must refuse a WAL-only future schema"
);
assert_eq!(
directory_bytes_and_modes(temp.path()),
family_before,
"reviewed-reconcile future refusal must leave the database family byte and mode neutral"
);
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let sidecars = existing_namespace_sidecars(&db_path);
assert!(!sidecars.is_empty(), "namespace sidecar fixture");
for sidecar in &sidecars {
fs::set_permissions(sidecar, fs::Permissions::from_mode(0o664)).unwrap();
}
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let permissive_family_before = directory_bytes_and_modes(temp.path());
let gated_error =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect_err("WAL-only future schema must precede authority-gated chmod");
assert!(
gated_error
.to_string()
.contains("newer than this br binary supports"),
"unexpected authority-gated WAL future error: {gated_error}"
);
assert_eq!(
directory_bytes_and_modes(temp.path()),
permissive_family_before,
"authority-gated future refusal must leave permissive sidecar modes unchanged"
);
drop(writer);
}
#[cfg(unix)]
#[test]
fn test_header_length_wal_preflight_rejects_invalid_32_byte_header() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("invalid_header_only_wal.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
let wal_path = database_sidecar_path(&db_path, "-wal");
let invalid_header = [0_u8; 32];
fs::write(&wal_path, invalid_header).unwrap();
let family_before = directory_bytes_and_modes(temp.path());
let error = SqliteStorage::open_with_timeout(&db_path, Some(50))
.expect_err("WAL length alone must not authorize a writable open");
assert!(
error.to_string().contains("WAL magic"),
"unexpected invalid WAL header error: {error}"
);
assert_eq!(
directory_bytes_and_modes(temp.path()),
family_before,
"invalid header-only WAL refusal must be byte and mode neutral"
);
}
#[test]
fn test_wal_preflight_stops_at_reused_or_partial_crash_tail() {
let salts = (0x1020_3040, 0x5060_7080);
for tail_kind in ["reused", "partial"] {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join(format!("{tail_kind}_wal_tail.db"));
let wal_path = database_sidecar_path(&db_path, "-wal");
let (mut wal, mut running_checksum) = synthetic_wal_header(salts);
append_synthetic_wal_frame(&mut wal, &mut running_checksum, 1, 1, salts, Some(73));
if tail_kind == "reused" {
append_synthetic_wal_frame(
&mut wal,
&mut running_checksum,
2,
2,
(salts.0 ^ 1, salts.1),
None,
);
} else {
wal.extend_from_slice(&[0xA5; 37]);
}
fs::write(&wal_path, &wal).unwrap();
let bytes_before = fs::read(&wal_path).unwrap();
let preflight = sqlite_wal_schema_preflight(&db_path).unwrap();
assert_eq!(
preflight.committed_user_version,
Some(73),
"{tail_kind} bytes after the last valid commit must not override page one"
);
assert_eq!(
fs::read(&wal_path).unwrap(),
bytes_before,
"{tail_kind} tail recovery must be byte neutral"
);
}
}
#[test]
fn test_wal_preflight_ignores_valid_uncommitted_page_one_tail() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("uncommitted_page_one_tail.db");
let wal_path = database_sidecar_path(&db_path, "-wal");
let salts = (0x1122_3344, 0x5566_7788);
let (mut wal, mut running_checksum) = synthetic_wal_header(salts);
append_synthetic_wal_frame(&mut wal, &mut running_checksum, 1, 1, salts, Some(81));
append_synthetic_wal_frame(&mut wal, &mut running_checksum, 1, 0, salts, Some(9_999));
fs::write(&wal_path, &wal).unwrap();
let bytes_before = fs::read(&wal_path).unwrap();
let preflight = sqlite_wal_schema_preflight(&db_path).unwrap();
assert_eq!(
preflight.committed_user_version,
Some(81),
"a checksum-valid page-one frame after the last commit is not effective"
);
assert_eq!(fs::read(&wal_path).unwrap(), bytes_before);
let no_commit_path = temp.path().join("only_uncommitted_page_one.db");
let no_commit_wal_path = database_sidecar_path(&no_commit_path, "-wal");
let (mut no_commit_wal, mut no_commit_checksum) = synthetic_wal_header(salts);
append_synthetic_wal_frame(
&mut no_commit_wal,
&mut no_commit_checksum,
1,
0,
salts,
Some(9_999),
);
fs::write(&no_commit_wal_path, &no_commit_wal).unwrap();
assert_eq!(
sqlite_wal_schema_preflight(&no_commit_path)
.unwrap()
.committed_user_version,
None,
"without any valid commit the database header remains authoritative"
);
}
#[test]
fn test_wal_preflight_rejects_checksum_valid_impossible_commit_size() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("impossible_commit_size.db");
let wal_path = database_sidecar_path(&db_path, "-wal");
let salts = (0x1357_9BDF, 0x2468_ACE0);
let (mut wal, mut running_checksum) = synthetic_wal_header(salts);
append_synthetic_wal_frame(&mut wal, &mut running_checksum, 2, 1, salts, None);
fs::write(&wal_path, &wal).unwrap();
let bytes_before = fs::read(&wal_path).unwrap();
let error = sqlite_wal_schema_preflight(&db_path)
.expect_err("a commit cannot shrink below the page carried by its commit frame");
assert!(
error.to_string().contains("database size 1")
&& error.to_string().contains("page number 2"),
"unexpected malformed commit refusal: {error}"
);
assert_eq!(
fs::read(&wal_path).unwrap(),
bytes_before,
"malformed commit refusal must not rewrite the WAL"
);
}
#[test]
fn test_committed_wal_without_readable_main_header_is_refused_byte_neutral() {
let current = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap();
let future = current.checked_add(1).unwrap();
let salts = (0x89AB_CDEF, 0x0123_4567);
for (main_kind, main_bytes) in [
("missing", None),
("short", Some(vec![0_u8; 20])),
("invalid", Some(vec![0_u8; 100])),
] {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join(format!("{main_kind}_main.db"));
if let Some(bytes) = &main_bytes {
fs::write(&db_path, bytes).unwrap();
}
let wal_path = database_sidecar_path(&db_path, "-wal");
let (mut wal, mut running_checksum) = synthetic_wal_header(salts);
append_synthetic_wal_frame(&mut wal, &mut running_checksum, 1, 1, salts, Some(future));
fs::write(&wal_path, &wal).unwrap();
let observed_main_before = fs::read(&db_path).ok();
let wal_before = fs::read(&wal_path).unwrap();
let error = SqliteStorage::open_with_timeout(&db_path, Some(50)).expect_err(
"a committed WAL cannot authorize creation or repair without a readable main header",
);
assert!(
error
.to_string()
.contains("committed WAL frames exist without a stable readable"),
"unexpected {main_kind} main-header refusal: {error}"
);
assert_eq!(
fs::read(&db_path).ok(),
observed_main_before,
"{main_kind} main database changed before WAL uncertainty was refused"
);
assert_eq!(
fs::read(&wal_path).unwrap(),
wal_before,
"{main_kind} main-header refusal rewrote the WAL"
);
}
}
#[cfg(unix)]
#[test]
fn test_future_schema_refusal_precedes_authority_gated_sidecar_heal() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("future_schema_permissive_sidecar.db");
let future_version = u32::try_from(CURRENT_SCHEMA_VERSION)
.unwrap()
.checked_add(1)
.unwrap();
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute(&format!("PRAGMA user_version = {future_version}"))
.unwrap();
storage
.conn
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
}
assert_eq!(
database_header_user_version(&db_path),
Some(future_version),
"the fixture must put the future version in the main header"
);
fs::set_permissions(&db_path, fs::Permissions::from_mode(0o600)).unwrap();
let sidecars = existing_namespace_sidecars(&db_path);
assert!(!sidecars.is_empty(), "namespace sidecar fixture");
let mut modes_before = Vec::new();
for sidecar in &sidecars {
fs::set_permissions(sidecar, fs::Permissions::from_mode(0o664)).unwrap();
modes_before.push((
sidecar.clone(),
fs::metadata(sidecar).unwrap().permissions().mode(),
));
}
let database_before = fs::read(&db_path).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
temp.path(),
&db_path,
Some(1_000),
)
.unwrap(),
);
let error =
SqliteStorage::open_with_timeout_under_write_authority(&db_path, Some(50), &authority)
.expect_err("future schema must be refused before any sidecar chmod");
assert!(
error
.to_string()
.contains("newer than this br binary supports"),
"unexpected future-schema sidecar error: {error}"
);
assert_eq!(fs::read(&db_path).unwrap(), database_before);
for (sidecar, mode_before) in modes_before {
assert_eq!(
fs::metadata(&sidecar).unwrap().permissions().mode(),
mode_before,
"future-schema refusal changed {} before returning",
sidecar.display()
);
}
}
#[test]
fn test_open_with_timeout_fences_same_cookie_user_version_change_after_compatibility() {
let current = u32::try_from(CURRENT_SCHEMA_VERSION).unwrap();
let changed_versions = [
("downgrade", current.checked_sub(1).unwrap()),
("future", current.checked_add(1).unwrap()),
];
for (label, changed_version) in changed_versions {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join(format!("same_cookie_{label}.db"));
let initial_cookie = {
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
apply_schema(&conn).unwrap();
conn.execute("DELETE FROM metadata WHERE key = 'runtime_schema_witness_v1'")
.unwrap();
let cookie = crate::storage::schema::runtime_schema_cookie(&conn).unwrap();
conn.close().unwrap();
cookie
};
SqliteStorage::arm_user_version_change_after_runtime_compatibility_for_test(
changed_version,
);
let error = SqliteStorage::open_with_timeout(&db_path, Some(50))
.expect_err("a version-only change after compatibility must fail closed");
assert!(
error.to_string().contains("runtime schema version"),
"unexpected {label} fence error: {error}"
);
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
assert_eq!(
connection_user_version(&conn),
Some(changed_version),
"the failed open must not rewrite the concurrently changed version"
);
assert_eq!(
crate::storage::schema::runtime_schema_cookie(&conn).unwrap(),
initial_cookie,
"the fixture must exercise a user_version-only same-cookie change"
);
let witness_count = conn
.query_row("SELECT COUNT(*) FROM metadata WHERE key = 'runtime_schema_witness_v1'")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer);
assert_eq!(
witness_count,
Some(0),
"a rejected same-cookie version change must not mint a runtime witness"
);
}
}
#[test]
fn test_open_uses_default_busy_timeout() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("lock_read_open_default.db");
let _ = SqliteStorage::open(&db_path).unwrap();
let lock_conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
lock_conn.execute("BEGIN IMMEDIATE").unwrap();
let opened = SqliteStorage::open(&db_path);
assert!(
opened.is_ok(),
"default open() should use the standard busy timeout under a concurrent write lock"
);
lock_conn.execute("COMMIT").unwrap();
}
#[test]
fn test_open_current_read_only_skips_metadata_default_seeding() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_current.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute_with_params(
"DELETE FROM metadata WHERE key = ?",
&[SqliteValue::from(METADATA_JSONL_CONTENT_HASH)],
)
.unwrap();
}
let storage = SqliteStorage::open_current_read_only(&db_path)
.unwrap()
.expect("current DB should open read-only");
let rows = storage
.conn
.query_with_params(
"SELECT 1 FROM metadata WHERE key = ? LIMIT 1",
&[SqliteValue::from(METADATA_JSONL_CONTENT_HASH)],
)
.unwrap();
assert!(
rows.is_empty(),
"read-only current open must not reseed missing metadata defaults"
);
}
/// Snapshot, open read-only, drop, snapshot; return the contract violations.
fn read_only_open_diffs(db_path: &Path) -> Vec<FamilyByteDiff> {
let before = database_family_snapshot(db_path).unwrap();
let storage = SqliteStorage::open_current_read_only(db_path)
.unwrap()
.expect("current DB should open read-only");
drop(storage);
let after = database_family_snapshot(db_path).unwrap();
database_family_read_only_diffs(&before, &after)
}
#[test]
fn open_current_read_only_is_observational_without_wal() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_observational.db");
drop(SqliteStorage::open(&db_path).unwrap());
let diffs = read_only_open_diffs(&db_path);
assert!(
diffs.is_empty(),
"read-only open changed the database family: {diffs:#?}"
);
}
#[test]
fn open_current_read_only_is_observational_with_live_wal() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_observational_wal.db");
// A peer handle stays open so the writer's drop sees another opener
// and skips its exit checkpoint (#270), leaving frames in the WAL.
let peer = SqliteStorage::open(&db_path).unwrap();
{
let mut writer = SqliteStorage::open(&db_path).unwrap();
let issue = make_issue(
"obs-wal-1",
"wal resident",
Status::Open,
2,
None,
Utc::now(),
None,
);
writer.create_issue(&issue, "test").unwrap();
}
let wal = PathBuf::from(format!("{}-wal", db_path.to_string_lossy()));
assert!(
fs::metadata(&wal).is_ok_and(|meta| meta.len() > 0),
"fixture must leave an uncheckpointed WAL"
);
let diffs = read_only_open_diffs(&db_path);
assert!(
diffs.is_empty(),
"read-only open with a live WAL changed the database family: {diffs:#?}"
);
drop(peer);
}
#[test]
fn open_current_read_only_is_observational_with_leftover_shm() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_observational_shm.db");
drop(SqliteStorage::open(&db_path).unwrap());
drop(SqliteStorage::open(&db_path).unwrap());
let shm = PathBuf::from(format!("{}-shm", db_path.to_string_lossy()));
assert!(
shm.exists(),
"fixture must leave a WAL-index sidecar behind"
);
let diffs = read_only_open_diffs(&db_path);
assert!(
diffs.is_empty(),
"read-only open with a leftover -shm changed the database family: {diffs:#?}"
);
}
#[test]
fn database_family_read_only_diffs_reports_offsets_and_exempts_reader_marks() {
let mut before: DatabaseFamilySnapshot = BTreeMap::new();
before.insert(String::new(), Some(vec![0_u8; 128]));
before.insert("-shm".to_string(), Some(vec![0_u8; 256]));
before.insert("-wal".to_string(), None);
before.insert("-journal".to_string(), None);
let mut after = before.clone();
assert!(database_family_read_only_diffs(&before, &after).is_empty());
// Reader-mark bytes may change; anything else in -shm may not.
after.get_mut("-shm").unwrap().as_mut().unwrap()[104] = 0x14;
assert!(database_family_read_only_diffs(&before, &after).is_empty());
after.get_mut("-shm").unwrap().as_mut().unwrap()[8] = 1;
let diffs = database_family_read_only_diffs(&before, &after);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].artifact, "-shm");
assert_eq!(diffs[0].kind, "bytes");
assert_eq!(diffs[0].offsets, vec![8]);
// Header change counter bytes in the main file are always a violation.
let mut after = before.clone();
after.get_mut("").unwrap().as_mut().unwrap()[24] = 7;
let diffs = database_family_read_only_diffs(&before, &after);
assert_eq!(diffs[0].artifact, "main database file");
assert_eq!(diffs[0].offsets, vec![24]);
assert_eq!((diffs[0].before[0], diffs[0].after[0]), (0, 7));
// Presence and length changes are reported by kind.
let mut after = before.clone();
after.insert("-wal".to_string(), Some(vec![1, 2, 3]));
assert_eq!(
database_family_read_only_diffs(&before, &after)[0].kind,
"presence"
);
let mut after = before.clone();
after.get_mut("").unwrap().as_mut().unwrap().push(0);
assert_eq!(
database_family_read_only_diffs(&before, &after)[0].kind,
"length"
);
}
#[test]
fn probe_read_only_open_is_observational_on_fresh_database() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("probe.db");
drop(SqliteStorage::open(&db_path).unwrap());
let probe = probe_read_only_open_is_observational(&db_path).unwrap();
assert!(
probe.skipped.is_none(),
"probe was skipped: {:?}",
probe.skipped
);
assert!(
probe.opened,
"a current-schema database must open read-only on the copy"
);
assert!(
probe.diffs.is_empty(),
"probe found violations: {:#?}",
probe.diffs
);
assert!(probe.copied_bytes > 0);
// The probe never touches the caller's family: the original still opens.
assert!(
SqliteStorage::open_current_read_only(&db_path)
.unwrap()
.is_some()
);
}
#[test]
fn test_open_current_read_only_declines_stale_schema_header() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_stale.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage.conn.execute("PRAGMA user_version = 0").unwrap();
}
assert!(
SqliteStorage::open_current_read_only(&db_path)
.unwrap()
.is_none(),
"stale schema headers must decline the current-schema read-only path"
);
}
#[test]
fn test_open_current_read_only_reports_runtime_incomplete_current_schema() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("readonly_runtime_incomplete.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage.conn.execute("DROP TABLE labels").unwrap();
assert_eq!(
connection_user_version(&storage.conn),
Some(u32::try_from(CURRENT_SCHEMA_VERSION).unwrap()),
"the fixture must retain the current version stamp"
);
}
let storage = SqliteStorage::open_current_read_only(&db_path)
.unwrap()
.expect(
"the exact-version read-only handle is still needed for pending-state inspection",
);
assert!(
!storage.fast_open_runtime_schema_is_compatible(),
"the fast-open caller must route runtime-incomplete schemas through ordinary healing"
);
}
#[test]
fn test_reviewed_reconcile_opens_decline_unknown_future_schema() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("reviewed_future_schema.db");
let future_version = CURRENT_SCHEMA_VERSION
.checked_add(1)
.expect("schema version increment");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute(&format!("PRAGMA user_version = {future_version}"))
.unwrap();
}
assert!(
SqliteStorage::open_current_read_only(&db_path)
.unwrap()
.is_none(),
"read-only planning must refuse an unknown future schema"
);
assert!(
SqliteStorage::open_current_for_reconcile(&db_path, Some(100))
.unwrap()
.is_none(),
"reviewed apply must refuse an unknown future schema before mutation"
);
}
#[test]
#[ignore = "superseded by the merge decision keeping shipped auto-migration on ordinary \
opens (open_auto_migrates_legacy_integer_datetimes_and_done_status); the \
reviewed migrate-schema lifecycle remains the explicit operator surface"]
fn test_open_refuses_runtime_compatible_legacy_db_without_reviewed_migration() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("legacy_runtime_compatible.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("DROP INDEX IF EXISTS idx_issues_external_ref_unique")
.unwrap();
storage.conn.execute("PRAGMA user_version = 0").unwrap();
}
let error = SqliteStorage::open(&db_path)
.expect_err("ordinary open must not cross a schema-version boundary");
assert!(
error.to_string().contains("br doctor migrate-schema plan"),
"refusal must provide the reviewed migration command: {error}"
);
let unchanged = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
let user_version = unchanged
.query_row("PRAGMA user_version")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap();
assert_eq!(
user_version, 0,
"refused ordinary open must not stamp the stale database"
);
let indexes: HashSet<String> = unchanged
.query("SELECT name FROM sqlite_master WHERE type='index'")
.unwrap()
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(str::to_owned))
.collect();
assert!(
!indexes.contains("idx_issues_external_ref_unique"),
"refused ordinary open must not repair DDL as a side effect"
);
}
#[test]
fn test_open_repairs_missing_canonical_indexes_even_when_user_version_is_current() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("current_version_missing_index.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("DROP INDEX IF EXISTS idx_issues_external_ref_unique")
.unwrap();
}
let reopened = SqliteStorage::open(&db_path).unwrap();
let user_version = reopened
.conn
.query_row("PRAGMA user_version")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap();
assert_eq!(
user_version,
i64::from(CURRENT_SCHEMA_VERSION),
"reopen should restore the current schema version"
);
// Use PRAGMA index_list instead of sqlite_master (more reliable in fsqlite)
let index_rows = reopened.conn.query("PRAGMA index_list('issues')").unwrap();
let index_names: HashSet<String> = index_rows
.iter()
.filter_map(|row| row.get(1).and_then(SqliteValue::as_text).map(str::to_owned))
.collect();
assert!(
index_names.contains("idx_issues_external_ref_unique"),
"reopen should recreate missing canonical indexes, got: {index_names:?}"
);
}
#[test]
fn test_open_repairs_current_version_nullable_blocked_cache_table() {
let temp = TempDir::new().unwrap();
let db_path = temp
.path()
.join("current_version_nullable_blocked_cache.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute("DROP TABLE blocked_issues_cache")
.unwrap();
storage
.conn
.execute(
"CREATE TABLE blocked_issues_cache (
issue_id TEXT PRIMARY KEY,
blocked_by TEXT,
blocked_at DATETIME
)",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO blocked_issues_cache (issue_id, blocked_by, blocked_at)
VALUES ('bd-null-cache', NULL, CURRENT_TIMESTAMP)",
)
.unwrap();
storage
.conn
.execute(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))
.unwrap();
}
let reopened = SqliteStorage::open(&db_path).unwrap();
let column_rows = reopened
.conn
.query("PRAGMA table_info(blocked_issues_cache)")
.unwrap();
let mut blocked_by_not_null = false;
for row in &column_rows {
if row.get(1).and_then(SqliteValue::as_text) == Some("blocked_by") {
blocked_by_not_null = row
.get(3)
.and_then(SqliteValue::as_integer)
.is_some_and(|value| value != 0);
}
}
assert!(
blocked_by_not_null,
"current-version open should repair nullable blocked_by cache columns"
);
let null_rows = reopened
.conn
.query_row("SELECT COUNT(*) FROM blocked_issues_cache WHERE blocked_by IS NULL")
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(
null_rows, 0,
"current-version open should discard malformed derived cache rows"
);
}
#[test]
fn test_open_repairs_current_version_legacy_kv_primary_key_tables() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("legacy_kv_primary_keys.db");
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_config("issue_prefix", "legacy").unwrap();
storage.set_metadata("project", "legacy-project").unwrap();
storage
.conn
.execute("DROP INDEX IF EXISTS idx_config_key")
.unwrap();
storage.conn.execute("DROP TABLE config").unwrap();
storage
.conn
.execute("CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.unwrap();
storage
.conn
.execute("INSERT INTO config (key, value) VALUES ('issue_prefix', 'legacy')")
.unwrap();
storage
.conn
.execute("DROP INDEX IF EXISTS idx_metadata_key")
.unwrap();
storage.conn.execute("DROP TABLE metadata").unwrap();
storage
.conn
.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.unwrap();
storage
.conn
.execute("INSERT INTO metadata (key, value) VALUES ('project', 'legacy-project')")
.unwrap();
}
let reopened = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
reopened.get_config("issue_prefix").unwrap(),
Some("legacy".to_string())
);
assert_eq!(
reopened.get_metadata("project").unwrap(),
Some("legacy-project".to_string())
);
// Use PRAGMA table_info to verify the repair (sqlite_master can
// return inconsistent results in fsqlite).
// Check that the `key` column no longer has pk flag set.
let config_has_pk = reopened
.conn
.query("PRAGMA table_info('config')")
.unwrap()
.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
});
let metadata_has_pk = reopened
.conn
.query("PRAGMA table_info('metadata')")
.unwrap()
.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
});
assert!(
!config_has_pk,
"legacy config primary key should be rebuilt to the canonical shape"
);
assert!(
!metadata_has_pk,
"legacy metadata primary key should be rebuilt to the canonical shape"
);
}
#[test]
fn test_upsert_issue_for_import_coalesces_optional_text_fields_to_empty_strings() {
let storage = SqliteStorage::open_memory().unwrap();
let issue = Issue {
id: "bd-import-null-optional-text".to_string(),
title: "Import null optional text".to_string(),
..Issue::default()
};
storage.upsert_issue_for_import(&issue).unwrap();
let row = storage
.conn
.query_row_with_params(
"SELECT
typeof(description), typeof(design), typeof(acceptance_criteria), typeof(notes),
typeof(owner), typeof(created_by), typeof(close_reason), typeof(closed_by_session),
typeof(source_system), typeof(source_repo), typeof(deleted_by), typeof(delete_reason),
typeof(original_type), typeof(sender),
description, design, acceptance_criteria, notes, owner, created_by, close_reason,
closed_by_session, source_system, source_repo, deleted_by, delete_reason,
original_type, sender
FROM issues WHERE id = ?",
&[SqliteValue::from(issue.id.as_str())],
)
.unwrap();
for index in 0..14 {
assert_eq!(
row.get(index).and_then(SqliteValue::as_text),
Some("text"),
"column {index} should store an empty string, not NULL"
);
}
for index in 14..23 {
assert_eq!(
row.get(index).and_then(SqliteValue::as_text),
Some(""),
"column {index} should coalesce missing optional text to ''"
);
}
assert_eq!(
row.get(23).and_then(SqliteValue::as_text),
Some("."),
"source_repo should coalesce missing values to '.'"
);
for index in 24..28 {
assert_eq!(
row.get(index).and_then(SqliteValue::as_text),
Some(""),
"column {index} should coalesce missing optional text to ''"
);
}
}
/// Regression test for issue #263 (a): import upsert must not
/// cascade-drop child rows. Pre-fix, `upsert_issue_for_import`
/// did `DELETE FROM issues WHERE id = ?` then `INSERT`, and the
/// child tables' `ON DELETE CASCADE` foreign keys swept events,
/// labels, deps, and comments out from under the issue every
/// time an import touched it.
#[test]
fn test_upsert_issue_for_import_preserves_child_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
let stamp = Utc.with_ymd_and_hms(2026, 4, 26, 12, 0, 0).unwrap();
let issue = make_issue(
"bd-import-preserve-child",
"Original title",
Status::Open,
2,
None,
stamp,
None,
);
storage.create_issue(&issue, "tester").unwrap();
let events_before = storage.get_events(&issue.id, 20).unwrap();
assert!(
!events_before.is_empty(),
"fixture: create_issue should produce at least one event row"
);
let mut imported = issue.clone();
imported.title = "Imported title".to_string();
imported.updated_at = stamp + chrono::Duration::minutes(1);
storage
.upsert_issue_for_import(&imported)
.expect("import upsert should run an in-place UPDATE for an existing id");
let updated = storage.get_issue(&issue.id).unwrap().unwrap();
assert_eq!(updated.title, "Imported title");
assert_eq!(
storage.get_events(&issue.id, 20).unwrap().len(),
events_before.len(),
"child events must survive an import that touches the parent row",
);
assert!(
!storage
.has_missing_issue_reference("events", "issue_id")
.unwrap(),
"import upsert must not leave dangling event rows referencing a deleted parent",
);
}
/// Regression test for issue #263 (a) — secondary contract: import
/// upsert must heal a malformed-but-present row by overwriting it,
/// not bail because the existing data fails to deserialize. The
/// existence probe is a narrow `SELECT 1` rather than parsing the
/// row through `get_issue_from_conn` for exactly this reason.
#[test]
fn test_upsert_issue_for_import_overwrites_malformed_existing_row() {
let mut storage = SqliteStorage::open_memory().unwrap();
let stamp = Utc.with_ymd_and_hms(2026, 4, 26, 12, 0, 0).unwrap();
let issue = make_issue(
"bd-import-heals-malformed",
"Original title",
Status::Open,
2,
None,
stamp,
None,
);
storage.create_issue(&issue, "tester").unwrap();
// Stomp the persisted status with a value the Status enum
// doesn't accept. A subsequent import of the same id must
// still succeed — the existence check stops at SELECT 1.
storage
.conn
.execute_with_params(
"UPDATE issues SET status = ? WHERE id = ?",
&[
SqliteValue::from("not-a-status"),
SqliteValue::from(issue.id.as_str()),
],
)
.unwrap();
let mut imported = issue.clone();
imported.title = "Healed title".to_string();
imported.updated_at = stamp + chrono::Duration::minutes(1);
storage
.upsert_issue_for_import(&imported)
.expect("import upsert should overwrite the malformed row");
let updated = storage.get_issue(&issue.id).unwrap().unwrap();
assert_eq!(updated.title, "Healed title");
assert_eq!(updated.status, Status::Open);
}
/// Regression test for issue #263 (b): a write-probe update that
/// matches zero rows must be reported as a probe failure, even
/// when ROLLBACK succeeds. Pre-fix, `Ok(0)` from the probe was
/// treated as a successful diagnostic — turning the very signal
/// the probe was built to surface ("the issue isn't write-
/// addressable through this mutation path") into a false
/// negative.
#[test]
fn test_finish_issue_mutation_write_probe_rejects_zero_row_update() {
let err = finish_issue_mutation_write_probe(Ok(0), Ok(0))
.expect_err("zero-row probe must surface as failure");
assert!(
err.to_string().contains("write probe did not find issue"),
"unexpected error: {err}",
);
}
/// Regression test for issue #263 (b): when both the probe update
/// matches zero rows AND the rollback returns an error, the
/// zero-row diagnostic remains the primary source while the rollback
/// failure is also surfaced as an unknown transaction state.
#[test]
fn test_finish_issue_mutation_write_probe_composes_zero_row_and_rollback_errors() {
let err = finish_issue_mutation_write_probe(
Ok(0),
Err(FrankenError::Internal("rollback failed".to_string())),
)
.expect_err("zero-row probe and rollback failure must both surface");
let message = err.to_string();
assert!(
message.contains("write probe did not find issue"),
"{message}"
);
assert!(message.contains("rollback failed"), "{message}");
assert!(
message.contains("transaction state is unknown"),
"{message}"
);
}
#[test]
fn test_pragmas_are_set_correctly() {
let storage = SqliteStorage::open_memory().unwrap();
// Check foreign keys are enabled
assert!(
SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap(),
"Foreign keys should be enabled"
);
// Check journal mode (memory DBs use 'memory' mode)
let mode = storage
.conn
.query_row("PRAGMA journal_mode")
.unwrap()
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
assert!(
mode.to_lowercase() == "wal" || mode.to_lowercase() == "memory",
"Journal mode should be WAL or memory"
);
}
#[test]
fn test_foreign_key_restore_reports_noop_inside_transaction() {
let storage = SqliteStorage::open_memory().unwrap();
storage.conn.execute("PRAGMA foreign_keys = OFF").unwrap();
storage.conn.execute("BEGIN").unwrap();
let err = SqliteStorage::finish_foreign_key_suppressed_result(
&storage.conn,
"test operation",
Ok::<(), BeadsError>(()),
)
.unwrap_err();
storage.conn.execute("ROLLBACK").unwrap();
storage.conn.execute("PRAGMA foreign_keys = ON").unwrap();
assert!(
matches!(
err,
BeadsError::Config(ref message)
if message.contains("test operation") && message.contains("remained OFF")
),
"restore failure should be returned instead of reporting success: {err}"
);
assert!(SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap());
}
#[test]
fn test_foreign_key_restore_combines_original_error_with_restore_error() {
let storage = SqliteStorage::open_memory().unwrap();
storage.conn.execute("PRAGMA foreign_keys = OFF").unwrap();
storage.conn.execute("BEGIN").unwrap();
let err = SqliteStorage::finish_foreign_key_suppressed_result(
&storage.conn,
"failing test operation",
Err::<(), _>(BeadsError::Config("original write failed".to_string())),
)
.unwrap_err();
storage.conn.execute("ROLLBACK").unwrap();
storage.conn.execute("PRAGMA foreign_keys = ON").unwrap();
match err {
BeadsError::WithContext { context, source } => {
assert!(context.contains("failing test operation"));
assert!(context.contains("could not be re-enabled"));
assert!(source.to_string().contains("original write failed"));
}
other => {
assert!(
matches!(other, BeadsError::WithContext { .. }),
"expected combined WithContext error"
);
}
}
assert!(SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap());
}
#[test]
fn test_blocked_cache_refresh_restores_foreign_keys_after_rebuild() {
let storage = SqliteStorage::open_memory().unwrap();
storage.conn.execute("PRAGMA foreign_keys = OFF").unwrap();
storage
.refresh_blocked_cache_after_commit("test refresh", &BlockedCacheRefreshPlan::Full)
.unwrap();
assert!(
SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap(),
"blocked-cache refresh must restore foreign key enforcement"
);
}
#[test]
fn test_blocked_cache_refresh_error_with_foreign_keys_off_is_not_deferred() {
let storage = SqliteStorage::open_memory().unwrap();
storage.conn.execute("PRAGMA foreign_keys = OFF").unwrap();
let err = storage
.handle_blocked_cache_refresh_error(
"test mutation",
BeadsError::Config("refresh failed".to_string()),
)
.unwrap_err();
match err {
BeadsError::WithContext { context, source } => {
assert!(context.contains("test mutation"));
assert!(context.contains("foreign key enforcement is OFF"));
assert!(source.to_string().contains("refresh failed"));
}
other => assert!(
matches!(other, BeadsError::WithContext { .. }),
"expected WithContext error"
),
}
storage.conn.execute("PRAGMA foreign_keys = ON").unwrap();
assert!(SqliteStorage::foreign_keys_enabled(&storage.conn).unwrap());
}
#[test]
fn test_create_duplicate_id_fails() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-dup-1", "First issue", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
// Try to create another issue with the same ID
let dup = make_issue("bd-dup-1", "Duplicate", Status::Open, 2, None, t1, None);
let result = storage.create_issue(&dup, "tester");
assert!(result.is_err(), "Creating duplicate ID should fail");
}
#[test]
fn test_set_export_hashes_deduplicates_duplicate_issue_ids_last_value_wins() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-hash-1", "Hash target", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
let inserted = storage
.set_export_hashes(&[
("bd-hash-1".to_string(), "hash-old".to_string()),
("bd-hash-1".to_string(), "hash-new".to_string()),
])
.unwrap();
assert_eq!(
inserted, 1,
"duplicate issue IDs should collapse to one row"
);
let (content_hash, _) = storage.get_export_hash("bd-hash-1").unwrap().unwrap();
assert_eq!(content_hash, "hash-new");
}
#[test]
fn test_insert_export_hashes_after_clear_skips_stale_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
for issue_id in ["bd-hash-1", "bd-hash-2"] {
let issue = make_issue(issue_id, "Hash target", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
storage
.set_export_hashes(&[
("bd-hash-1".to_string(), "stale-a".to_string()),
("bd-hash-2".to_string(), "stale-b".to_string()),
])
.unwrap();
let inserted = storage
.with_write_transaction(|storage| {
storage.clear_all_export_hashes_in_tx()?;
storage.insert_export_hashes_after_clear_in_tx(&[
("bd-hash-1".to_string(), "fresh-a".to_string()),
("bd-hash-1".to_string(), "fresh-a-final".to_string()),
("bd-hash-2".to_string(), "fresh-b".to_string()),
])
})
.unwrap();
assert_eq!(inserted, 2);
assert_eq!(
storage.get_export_hash("bd-hash-1").unwrap().unwrap().0,
"fresh-a-final"
);
assert_eq!(
storage.get_export_hash("bd-hash-2").unwrap().unwrap().0,
"fresh-b"
);
let row_count = storage
.execute_raw_query("SELECT COUNT(*) FROM export_hashes")
.unwrap()
.first()
.and_then(|row| row.first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(row_count, 2);
}
#[test]
fn test_set_changed_export_hashes_skips_unchanged_rows() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue = make_issue("bd-hash-1", "Hash target", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
storage
.set_export_hashes(&[("bd-hash-1".to_string(), "hash-stable".to_string())])
.unwrap();
let (_, exported_at) = storage.get_export_hash("bd-hash-1").unwrap().unwrap();
let unchanged = storage
.set_changed_export_hashes_in_tx(&[(
"bd-hash-1".to_string(),
"hash-stable".to_string(),
)])
.unwrap();
assert_eq!(unchanged, 0);
assert_eq!(
storage.get_export_hash("bd-hash-1").unwrap().unwrap(),
("hash-stable".to_string(), exported_at)
);
let changed = storage
.set_changed_export_hashes_in_tx(&[("bd-hash-1".to_string(), "hash-new".to_string())])
.unwrap();
assert_eq!(changed, 1);
assert_eq!(
storage.get_export_hash("bd-hash-1").unwrap().unwrap().0,
"hash-new"
);
}
#[test]
fn test_set_export_hashes_updates_large_existing_batch() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let initial_hashes: Vec<(String, String)> = (0..40)
.map(|idx| {
let issue_id = format!("bd-hash-{idx:02}");
let issue = make_issue(
&issue_id,
&format!("Hash target {idx}"),
Status::Open,
2,
None,
created_at,
None,
);
storage.create_issue(&issue, "tester").unwrap();
(issue_id, format!("hash-a-{idx:02}"))
})
.collect();
storage.set_export_hashes(&initial_hashes).unwrap();
let updated_hashes: Vec<(String, String)> = initial_hashes
.iter()
.map(|(issue_id, _)| (issue_id.clone(), format!("hash-b-{issue_id}")))
.collect();
let updated = storage.set_export_hashes(&updated_hashes).unwrap();
assert_eq!(updated, updated_hashes.len());
let (content_hash, _) = storage
.get_export_hash("bd-hash-39")
.unwrap()
.expect("updated export hash");
assert_eq!(content_hash, "hash-b-bd-hash-39");
}
#[test]
fn test_set_export_hashes_rewrites_large_mixed_prefix_batch_on_file_db() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue_ids: Vec<String> = (0..160)
.map(|idx| {
let prefix = if idx % 2 == 0 { "bd" } else { "br" };
format!("{prefix}-hash-{idx:03}")
})
.collect();
let initial_hashes: Vec<(String, String)> = issue_ids
.iter()
.map(|issue_id| {
let issue = make_issue(
issue_id,
&format!("Hash target {issue_id}"),
Status::Open,
2,
None,
created_at,
None,
);
storage.create_issue(&issue, "tester").unwrap();
(issue_id.clone(), format!("hash-a-{issue_id}"))
})
.collect();
storage.set_export_hashes(&initial_hashes).unwrap();
let mut rewritten_hashes: Vec<(String, String)> = issue_ids
.iter()
.rev()
.map(|issue_id| (issue_id.clone(), format!("hash-b-{issue_id}")))
.collect();
rewritten_hashes.push(("bd-hash-000".to_string(), "hash-c-bd-hash-000".to_string()));
rewritten_hashes.push(("br-hash-001".to_string(), "hash-c-br-hash-001".to_string()));
let updated = storage.set_export_hashes(&rewritten_hashes).unwrap();
assert_eq!(updated, issue_ids.len());
let (first_hash, _) = storage
.get_export_hash("bd-hash-000")
.unwrap()
.expect("updated export hash for bd-hash-000");
assert_eq!(first_hash, "hash-c-bd-hash-000");
let (second_hash, _) = storage
.get_export_hash("br-hash-001")
.unwrap()
.expect("updated export hash for br-hash-001");
assert_eq!(second_hash, "hash-c-br-hash-001");
let row_count = storage
.execute_raw_query("SELECT COUNT(*) FROM export_hashes")
.unwrap()
.first()
.and_then(|row| row.first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(row_count, i64::try_from(issue_ids.len()).unwrap_or(-1));
}
#[test]
fn test_rebuild_blocked_cache_rewrites_large_existing_batch_on_file_db() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let issue_pairs: Vec<(String, String)> = (0..160)
.map(|idx| {
let prefix = if idx % 2 == 0 { "bd" } else { "br" };
(
format!("{prefix}-blocked-{idx:03}"),
format!("{prefix}-blocker-{idx:03}"),
)
})
.collect();
for (blocked_id, blocker_id) in &issue_pairs {
let blocked = make_issue(
blocked_id,
&format!("Blocked target {blocked_id}"),
Status::Open,
2,
None,
created_at,
None,
);
let blocker = make_issue(
blocker_id,
&format!("Blocking source {blocker_id}"),
Status::Open,
2,
None,
created_at,
None,
);
storage.create_issue(&blocked, "tester").unwrap();
storage.create_issue(&blocker, "tester").unwrap();
storage
.add_dependency(
blocked_id,
blocker_id,
DependencyType::Blocks.as_str(),
"tester",
)
.unwrap();
}
let rebuilt = storage.rebuild_blocked_cache(true).unwrap();
assert_eq!(rebuilt, issue_pairs.len());
for _ in 0..4 {
let rewritten = storage.rebuild_blocked_cache(true).unwrap();
assert_eq!(rewritten, issue_pairs.len());
}
let row_count = storage
.execute_raw_query("SELECT COUNT(*) FROM blocked_issues_cache")
.unwrap()
.first()
.and_then(|row| row.first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
assert_eq!(row_count, i64::try_from(issue_pairs.len()).unwrap_or(-1));
}
#[test]
fn test_diag_data_visibility() {
use fsqlite_types::value::SqliteValue;
// Simplest possible reproduction
let conn = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
conn.execute("CREATE TABLE t (k TEXT, v TEXT)").unwrap();
conn.execute_with_params(
"INSERT INTO t VALUES (?, ?)",
&[SqliteValue::from("a"), SqliteValue::from("b")],
)
.unwrap();
// 1: count without WHERE
let r1 = conn
.query_with_params("SELECT count(*) FROM t", &[])
.unwrap();
eprintln!(
"[DIAG] 1. count(*) no WHERE: {:?}",
r1.first().map(Row::values)
);
// 2: count with literal WHERE
let r2 = conn
.query_with_params("SELECT count(*) FROM t WHERE k = 'a'", &[])
.unwrap();
eprintln!(
"[DIAG] 2. count(*) literal WHERE: {:?}",
r2.first().map(Row::values)
);
// 3: count with bind WHERE
let explain3 = conn
.prepare("SELECT count(*) FROM t WHERE k = ?")
.map_or_else(|e| format!("PREPARE ERROR: {e}"), |s| s.explain());
for line in explain3.lines() {
eprintln!("[DIAG] 3.E| {line}");
}
if explain3.is_empty() {
eprintln!("[DIAG] 3.E| (empty)");
}
let r3 = conn
.query_with_params(
"SELECT count(*) FROM t WHERE k = ?",
&[SqliteValue::from("a")],
)
.unwrap();
eprintln!(
"[DIAG] 3. count(*) bind WHERE: {:?}",
r3.first().map(Row::values)
);
// Also get EXPLAIN for the working non-aggregate version
let explain4 = conn
.prepare("SELECT k FROM t WHERE k = ?")
.map_or_else(|e| format!("PREPARE ERROR: {e}"), |s| s.explain());
for line in explain4.lines() {
eprintln!("[DIAG] 4.E| {line}");
}
if explain4.is_empty() {
eprintln!("[DIAG] 4.E| (empty)");
}
// 4: select with bind WHERE (no aggregate)
let r4 = conn
.query_with_params("SELECT k FROM t WHERE k = ?", &[SqliteValue::from("a")])
.unwrap();
eprintln!(
"[DIAG] 4. select k bind WHERE: {:?}",
r4.first().map(Row::values)
);
// 5: count(k) with bind WHERE
let r5 = conn
.query_with_params(
"SELECT count(k) FROM t WHERE k = ?",
&[SqliteValue::from("a")],
)
.unwrap();
eprintln!(
"[DIAG] 5. count(k) bind WHERE: {:?}",
r5.first().map(Row::values)
);
// 6: count with bind WHERE but no match
let r6 = conn
.query_with_params(
"SELECT count(*) FROM t WHERE k = ?",
&[SqliteValue::from("nonexistent")],
)
.unwrap();
eprintln!(
"[DIAG] 6. count(*) bind WHERE no match: {:?}",
r6.first().map(Row::values)
);
let c = r3
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
assert_eq!(c, 1, "count(*) with bind param WHERE should return 1");
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_diag_root_page_visibility() {
use fsqlite_types::value::SqliteValue;
// Create full beads schema and check which root pages are accessible
let conn = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
// Apply schema step by step, checking after each table
let tables = vec![(
"issues",
r"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 DEFAULT 0,
pinned INTEGER DEFAULT 0,
is_template INTEGER DEFAULT 0,
CHECK (
(status = 'closed' AND closed_at IS NOT NULL) OR
(status = 'tombstone') OR
(status NOT IN ('closed', 'tombstone') AND closed_at IS NULL)
)
)",
)];
for (name, sql) in &tables {
match conn.execute(sql) {
Ok(_) => eprintln!("[ROOT-DIAG] Created table {name} OK"),
Err(e) => eprintln!("[ROOT-DIAG] Failed to create table {name}: {e}"),
}
}
// Create first few indexes
let indexes = vec![
"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)",
"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",
"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'",
"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",
"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 OR is_template IS NULL)",
// Issue #354: `br ready` can be configured (workflow.status_groups.ready)
// to surface statuses beyond `open` (e.g. `rework`). The partial
// `idx_issues_ready` above only covers `status = 'open'`, so a widened
// ready group would fall back to a scan on the status leg. The partial
// predicate is a static migration string and cannot be widened to a
// per-repo dynamic group, so we add a non-partial `(status, priority,
// created_at)` index to keep the widened `status IN (...) ORDER BY
// priority, created_at` ready query index-covered. The tighter partial
// index still wins for the common default `[open]` group.
"CREATE INDEX IF NOT EXISTS idx_issues_status_priority_created ON issues(status, priority, created_at)",
];
for (i, sql) in indexes.iter().enumerate() {
match conn.execute(sql) {
Ok(_) => eprintln!("[ROOT-DIAG] Created index {} OK", i + 1),
Err(e) => eprintln!("[ROOT-DIAG] Failed to create index {}: {e}", i + 1),
}
}
// Try count(*) first (simplest possible query)
match conn.query_with_params("SELECT count(*) FROM sqlite_master", &[]) {
Ok(rows) => {
let count = rows
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] count(*) from sqlite_master: {count}");
}
Err(e) => eprintln!("[ROOT-DIAG] count(*) FAILED: {e}"),
}
// Try SELECT without ORDER BY
match conn.query_with_params("SELECT type, name, rootpage FROM sqlite_master", &[]) {
Ok(rows) => {
eprintln!("[ROOT-DIAG] sqlite_master entries (no ORDER BY):");
for row in &rows {
let vals = row.values();
let typ = vals.first().map(|v| format!("{v:?}")).unwrap_or_default();
let name = vals.get(1).map(|v| format!("{v:?}")).unwrap_or_default();
let rootpage = vals.get(2).and_then(SqliteValue::as_integer).unwrap_or(0);
eprintln!("[ROOT-DIAG] type={typ} name={name} rootpage={rootpage}");
}
}
Err(e) => eprintln!("[ROOT-DIAG] SELECT (no ORDER BY) FAILED: {e}"),
}
// Try SELECT with ORDER BY
match conn.query_with_params(
"SELECT type, name, rootpage FROM sqlite_master ORDER BY rootpage",
&[],
) {
Ok(rows) => {
eprintln!("[ROOT-DIAG] sqlite_master entries (ORDER BY):");
for row in &rows {
let vals = row.values();
let rootpage = vals.get(2).and_then(SqliteValue::as_integer).unwrap_or(0);
eprintln!("[ROOT-DIAG] rootpage={rootpage}");
}
}
Err(e) => eprintln!("[ROOT-DIAG] SELECT (ORDER BY) FAILED: {e}"),
}
// Try simple SELECT from issues table
match conn.query_with_params("SELECT count(*) FROM issues", &[]) {
Ok(rows) => {
let count = rows
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] count(*) from issues: {count}");
}
Err(e) => eprintln!("[ROOT-DIAG] count(*) from issues FAILED: {e}"),
}
let max_rootpage = 0i64;
// Also try: incrementally create indexes and check count(*) after each
eprintln!("[ROOT-DIAG] --- Incremental index creation with count check ---");
let conn2 = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
conn2
.execute("CREATE TABLE t (a TEXT, b TEXT, c TEXT, d TEXT, e TEXT)")
.unwrap();
for i in 1..=20 {
let col = ['a', 'b', 'c', 'd', 'e'][i % 5];
let sql = format!("CREATE INDEX IF NOT EXISTS idx_{i} ON t({col})");
match conn2.execute(&sql) {
Ok(_) => {}
Err(e) => {
eprintln!("[ROOT-DIAG] Index {i} creation FAILED: {e}");
break;
}
}
match conn2.query_with_params("SELECT count(*) FROM sqlite_master", &[]) {
Ok(rows) => {
let count = rows
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] After {i} indexes: count(*)={count}");
}
Err(e) => {
eprintln!("[ROOT-DIAG] After {i} indexes: count(*) FAILED: {e}");
break;
}
}
}
// Test multi-insert with explicit transactions
eprintln!("[ROOT-DIAG] --- Multi-insert test ---");
let conn3 = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
conn3
.execute("CREATE TABLE ev (id INTEGER PRIMARY KEY AUTOINCREMENT, msg TEXT)")
.unwrap();
for i in 0..5 {
conn3.execute("BEGIN IMMEDIATE").unwrap();
conn3
.execute_with_params(
"INSERT INTO ev (msg) VALUES (?)",
&[SqliteValue::from(format!("msg{i}"))],
)
.unwrap();
conn3.execute("COMMIT").unwrap();
}
let rows3 = conn3
.query_with_params("SELECT count(*) FROM ev", &[])
.unwrap();
let count3 = rows3
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] Multi-insert count: {count3} (expected 5)");
let all3 = conn3
.query_with_params("SELECT id, msg FROM ev", &[])
.unwrap();
for row in &all3 {
let id = row
.values()
.first()
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
let msg = row
.values()
.get(1)
.map(|v| format!("{v:?}"))
.unwrap_or_default();
eprintln!("[ROOT-DIAG] id={id} msg={msg}");
}
// Also test without explicit transactions (autocommit)
let conn4 = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
conn4
.execute("CREATE TABLE ev2 (id INTEGER PRIMARY KEY AUTOINCREMENT, msg TEXT)")
.unwrap();
for i in 0..5 {
conn4
.execute_with_params(
"INSERT INTO ev2 (msg) VALUES (?)",
&[SqliteValue::from(format!("msg{i}"))],
)
.unwrap();
}
let rows4 = conn4
.query_with_params("SELECT count(*) FROM ev2", &[])
.unwrap();
let count4 = rows4
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] Multi-insert (autocommit) count: {count4} (expected 5)");
let all4 = conn4
.query_with_params("SELECT id, msg FROM ev2", &[])
.unwrap();
for row in &all4 {
let id = row
.values()
.first()
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
let msg = row
.values()
.get(1)
.map(|v| format!("{v:?}"))
.unwrap_or_default();
eprintln!("[ROOT-DIAG] id={id} msg={msg}");
}
// Test events-like table with indexes and WHERE+ORDER BY
eprintln!("[ROOT-DIAG] --- Events-like test ---");
let conn5 = crate::franken_sync::Connection::open(":memory:".to_string()).unwrap();
conn5
.execute("CREATE TABLE issues2 (id TEXT PRIMARY KEY, title TEXT)")
.unwrap();
conn5.execute("CREATE TABLE ev3 (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, msg TEXT, created_at TEXT, FOREIGN KEY (issue_id) REFERENCES issues2(id))").unwrap();
conn5
.execute("CREATE INDEX idx_ev3_issue ON ev3(issue_id)")
.unwrap();
conn5
.execute("CREATE INDEX idx_ev3_created ON ev3(created_at)")
.unwrap();
conn5
.execute("INSERT INTO issues2 (id, title) VALUES ('test-001', 'Test')")
.unwrap();
for i in 0..5 {
conn5.execute("BEGIN IMMEDIATE").unwrap();
conn5
.execute_with_params(
"INSERT INTO ev3 (issue_id, msg, created_at) VALUES (?1, ?2, ?3)",
&[
SqliteValue::from("test-001"),
SqliteValue::from(format!("msg{i}")),
SqliteValue::from(format!("2024-01-0{} 00:00:00", i + 1)),
],
)
.unwrap();
conn5.execute("COMMIT").unwrap();
}
// Test count
let ev_count = conn5
.query_with_params("SELECT count(*) FROM ev3", &[])
.unwrap();
let c = ev_count
.first()
.and_then(|r| r.values().first())
.and_then(SqliteValue::as_integer)
.unwrap_or(-99);
eprintln!("[ROOT-DIAG] ev3 count: {c}");
// Test WHERE with bind (no order) - uses index_eq path
let ev_where = conn5
.query_with_params(
"SELECT id, msg FROM ev3 WHERE issue_id = ?1",
&[SqliteValue::from("test-001")],
)
.unwrap();
eprintln!("[ROOT-DIAG] ev3 WHERE bind: {} rows", ev_where.len());
// Test WHERE with literal (no bind) - uses full scan
let ev_literal = conn5
.query_with_params("SELECT id, msg FROM ev3 WHERE issue_id = 'test-001'", &[])
.unwrap();
eprintln!("[ROOT-DIAG] ev3 WHERE literal: {} rows", ev_literal.len());
// Test full scan (no WHERE)
let ev_all = conn5
.query_with_params("SELECT id, msg FROM ev3", &[])
.unwrap();
eprintln!("[ROOT-DIAG] ev3 ALL (no where): {} rows", ev_all.len());
// Test WHERE with ORDER BY
let ev_ordered = conn5
.query_with_params(
"SELECT id, msg FROM ev3 WHERE issue_id = ?1 ORDER BY created_at DESC, id DESC",
&[SqliteValue::from("test-001")],
)
.unwrap();
eprintln!("[ROOT-DIAG] ev3 WHERE+ORDER: {} rows", ev_ordered.len());
for row in &ev_ordered {
let id = row
.values()
.first()
.and_then(SqliteValue::as_integer)
.unwrap_or(-1);
let msg = row
.values()
.get(1)
.map(|v| format!("{v:?}"))
.unwrap_or_default();
eprintln!("[ROOT-DIAG] id={id} msg={msg}");
}
assert!(max_rootpage >= 0, "diagnostic test completed");
}
#[test]
fn test_get_issue_not_found_returns_none() {
let storage = SqliteStorage::open_memory().unwrap();
let result = storage.get_issue("nonexistent-id").unwrap();
assert!(
result.is_none(),
"Getting non-existent issue should return None"
);
}
#[test]
fn test_open_nonexistent_parent_fails() {
let result = SqliteStorage::open(Path::new("/nonexistent/path/to/db.db"));
assert!(
result.is_err(),
"Opening DB in non-existent directory should fail"
);
}
#[test]
fn test_list_issues_empty_db() {
let storage = SqliteStorage::open_memory().unwrap();
let filters = ListFilters::default();
let issues = storage.list_issues(&filters).unwrap();
assert!(issues.is_empty(), "Empty DB should return no issues");
}
#[test]
fn test_update_issue_not_found_fails() {
let mut storage = SqliteStorage::open_memory().unwrap();
let update = IssueUpdate {
title: Some("Updated title".to_string()),
..IssueUpdate::default()
};
let result = storage.update_issue("nonexistent-id", &update, "tester");
assert!(result.is_err(), "Updating non-existent issue should fail");
}
#[test]
fn test_list_issues_filter_by_title() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 8, 1, 0, 0, 0).unwrap();
// Create issues with different titles
let issue1 = make_issue(
"bd-s1",
"Fix authentication bug",
Status::Open,
2,
None,
t1,
None,
);
let issue2 = make_issue(
"bd-s2",
"Add user registration",
Status::Open,
2,
None,
t1,
None,
);
let issue3 = make_issue(
"bd-s3",
"Update documentation",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
storage.create_issue(&issue3, "tester").unwrap();
// Filter by title containing "bug"
let filters = ListFilters {
title_contains: Some("bug".to_string()),
..ListFilters::default()
};
let issues = storage.list_issues(&filters).unwrap();
assert_eq!(
issues.len(),
1,
"Should find one issue matching 'bug' in title"
);
assert_eq!(issues[0].id, "bd-s1");
}
#[test]
fn test_list_issues_reverse_default_sort() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 8, 1, 0, 0, 0).unwrap();
let t2 = Utc.with_ymd_and_hms(2025, 8, 2, 0, 0, 0).unwrap();
let issue_a = make_issue("bd-a", "A", Status::Open, 1, None, t1, None);
let issue_b = make_issue("bd-b", "B", Status::Open, 1, None, t2, None);
let issue_c = make_issue("bd-c", "C", Status::Open, 2, None, t1, None);
storage.create_issue(&issue_a, "tester").unwrap();
storage.create_issue(&issue_b, "tester").unwrap();
storage.create_issue(&issue_c, "tester").unwrap();
let filters = ListFilters {
reverse: true,
..ListFilters::default()
};
let issues = storage.list_issues(&filters).unwrap();
let ids: Vec<_> = issues.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["bd-c", "bd-a", "bd-b"]);
}
#[test]
fn test_list_issues_custom_sort_limit_uses_id_tiebreaker() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 8, 4, 0, 0, 0).unwrap();
for issue in [
make_issue(
"bd-list-b",
"Tie B",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-list-a",
"Tie A",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-list-c",
"Later",
Status::Open,
2,
None,
created_at - chrono::Duration::days(1),
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
let issues = storage
.list_issues(&ListFilters {
sort: Some("created".to_string()),
limit: Some(2),
..ListFilters::default()
})
.unwrap();
let ids: Vec<_> = issues.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(ids, vec!["bd-list-a", "bd-list-b"]);
}
#[test]
fn test_list_changelog_issues_matches_closed_list_projection() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 8, 1, 0, 0, 0).unwrap();
let t2 = Utc.with_ymd_and_hms(2025, 8, 2, 0, 0, 0).unwrap();
let t3 = Utc.with_ymd_and_hms(2025, 8, 3, 0, 0, 0).unwrap();
let mut closed_low = make_issue("bd-low", "Low", Status::Closed, 2, None, t1, None);
closed_low.closed_at = Some(t3);
let mut closed_high_new =
make_issue("bd-high-new", "High New", Status::Closed, 1, None, t3, None);
closed_high_new.closed_at = Some(t3);
let mut closed_high_old =
make_issue("bd-high-old", "High Old", Status::Closed, 1, None, t2, None);
closed_high_old.closed_at = Some(t2);
let open_issue = make_issue("bd-open", "Open", Status::Open, 0, None, t3, None);
let mut template = make_issue("bd-template", "Template", Status::Closed, 0, None, t3, None);
template.closed_at = Some(t3);
template.is_template = true;
for issue in [
closed_low,
closed_high_new,
closed_high_old,
open_issue,
template,
] {
storage.create_issue(&issue, "tester").unwrap();
}
let filters = ListFilters {
statuses: Some(vec![Status::Closed]),
include_closed: true,
..ListFilters::default()
};
let expected: Vec<_> = storage
.list_issues(&filters)
.unwrap()
.into_iter()
.map(|issue| ChangelogIssueRow {
id: issue.id,
title: issue.title,
priority: issue.priority,
issue_type: issue.issue_type,
created_at: issue.created_at,
closed_at: issue.closed_at,
})
.collect();
let actual = storage.list_changelog_issues().unwrap();
assert_eq!(
actual
.iter()
.map(|issue| issue.id.as_str())
.collect::<Vec<_>>(),
vec!["bd-high-new", "bd-high-old", "bd-low"]
);
assert_eq!(actual, expected);
}
#[test]
fn test_search_issues_full_text() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let issue1 = make_issue(
"bd-s1",
"Fix authentication bug",
Status::Open,
2,
None,
t1,
None,
);
let issue2 = make_issue(
"bd-s2",
"Add user registration",
Status::Open,
2,
None,
t1,
None,
);
let issue3 = make_issue(
"bd-s3",
"Update documentation",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
storage.create_issue(&issue3, "tester").unwrap();
let filters = ListFilters::default();
let results = storage.search_issues("authentication", &filters).unwrap();
assert_eq!(
results.len(),
1,
"Should find one issue matching 'authentication'"
);
assert_eq!(results[0].id, "bd-s1");
}
#[test]
fn test_search_issues_matches_case_insensitive_literal_substrings() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let literal_issue = make_issue(
"bd-s-literal",
"Literal %_ marker",
Status::Open,
2,
None,
t1,
None,
);
let mut description_issue = make_issue(
"bd-s-description",
"Description target",
Status::Open,
2,
None,
t1,
None,
);
description_issue.description = Some("Uppercase AUTHENTICATION token".to_string());
storage.create_issue(&literal_issue, "tester").unwrap();
storage.create_issue(&description_issue, "tester").unwrap();
let filters = ListFilters::default();
let wildcard_results = storage.search_issues("%_", &filters).unwrap();
let wildcard_ids: Vec<_> = wildcard_results
.iter()
.map(|issue| issue.id.as_str())
.collect();
assert_eq!(wildcard_ids, vec!["bd-s-literal"]);
let case_results = storage.search_issues("authentication", &filters).unwrap();
let case_ids: Vec<_> = case_results.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(case_ids, vec!["bd-s-description"]);
}
#[test]
fn test_count_closed_search_matches_deduplicates_matching_comments() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let mut direct_match = make_issue(
"bd-s-count-direct",
"needle in title",
Status::Closed,
2,
None,
created_at,
None,
);
direct_match.closed_at = Some(created_at);
let mut comment_match = make_issue(
"bd-s-count-comment",
"comment-only closed match",
Status::Closed,
2,
None,
created_at,
None,
);
comment_match.closed_at = Some(created_at);
let open_comment_match = make_issue(
"bd-s-count-open",
"open comment match",
Status::Open,
2,
None,
created_at,
None,
);
let mut closed_non_match = make_issue(
"bd-s-count-absent",
"closed without the token",
Status::Closed,
2,
None,
created_at,
None,
);
closed_non_match.closed_at = Some(created_at);
for issue in [
direct_match,
comment_match,
open_comment_match,
closed_non_match,
] {
storage.create_issue(&issue, "tester").unwrap();
}
for issue_id in ["bd-s-count-direct", "bd-s-count-comment", "bd-s-count-open"] {
storage
.add_comment(issue_id, "tester", "first NEEDLE comment")
.unwrap();
storage
.add_comment(issue_id, "tester", "second needle comment")
.unwrap();
}
assert_eq!(
storage
.count_closed_search_matches("NeEdLe", &ListFilters::default())
.unwrap(),
2,
"each closed issue must be counted once regardless of how many fields or comments match"
);
}
#[test]
fn test_search_issues_materialized_label_candidates_preserve_semantics() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let mut export_swarm = make_issue(
"bd-s-label-a",
"needle export swarm",
Status::Open,
2,
None,
t1,
None,
);
export_swarm.labels = vec!["export".to_string(), "swarm".to_string()];
let mut export_only = make_issue(
"bd-s-label-b",
"needle export only",
Status::Open,
2,
None,
t1,
None,
);
export_only.labels = vec!["export".to_string()];
let mut swarm_only = make_issue(
"bd-s-label-c",
"needle swarm only",
Status::Open,
2,
None,
t1,
None,
);
swarm_only.labels = vec!["swarm".to_string()];
let mut unrelated = make_issue(
"bd-s-label-d",
"needle unrelated",
Status::Open,
2,
None,
t1,
None,
);
unrelated.labels = vec!["other".to_string()];
for issue in [export_swarm, export_only, swarm_only, unrelated] {
storage.create_issue(&issue, "tester").unwrap();
}
let ids_for = |filters: ListFilters| {
storage
.search_issues("needle", &filters)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect::<Vec<_>>()
};
assert_eq!(
ids_for(ListFilters {
labels: Some(vec!["export".to_string(), "swarm".to_string()]),
..ListFilters::default()
}),
vec!["bd-s-label-a"]
);
assert_eq!(
ids_for(ListFilters {
labels_or: Some(vec!["export".to_string(), "swarm".to_string()]),
..ListFilters::default()
}),
vec!["bd-s-label-a", "bd-s-label-b", "bd-s-label-c"]
);
assert_eq!(
ids_for(ListFilters {
labels: Some(vec!["export".to_string()]),
labels_or: Some(vec!["swarm".to_string()]),
..ListFilters::default()
}),
vec!["bd-s-label-a"]
);
assert!(
ids_for(ListFilters {
labels: Some(vec!["missing".to_string()]),
..ListFilters::default()
})
.is_empty()
);
}
#[test]
fn test_redundant_single_label_fast_path_preserves_list_and_search_results() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let mut visible_a = make_issue(
"bd-s-cover-a",
"needle global alpha",
Status::Open,
1,
None,
t1,
None,
);
visible_a.labels = vec!["global".to_string()];
let mut visible_b = make_issue(
"bd-s-cover-b",
"needle global beta",
Status::Open,
2,
None,
t1 + chrono::Duration::minutes(1),
None,
);
visible_b.labels = vec!["global".to_string()];
let deferred_unlabeled = make_issue(
"bd-s-cover-c",
"needle deferred",
Status::Deferred,
0,
None,
t1 + chrono::Duration::minutes(2),
None,
);
for issue in [visible_a, visible_b, deferred_unlabeled] {
storage.create_issue(&issue, "tester").unwrap();
}
assert!(
storage
.single_label_covers_default_visible_issues(false, "global")
.unwrap()
);
assert!(
!storage
.single_label_covers_default_visible_issues(true, "global")
.unwrap()
);
let no_label_filters = ListFilters::default();
let global_label_filters = ListFilters {
labels: Some(vec!["global".to_string()]),
..ListFilters::default()
};
let broad_candidate_ids =
vec!["bd-s-cover-a".to_string(); REDUNDANT_LABEL_COVERAGE_MIN_CANDIDATES];
assert!(
storage
.redundant_default_visible_single_label_filter(
&global_label_filters,
Some(&broad_candidate_ids),
)
.unwrap()
);
let narrow_candidate_ids =
vec!["bd-s-cover-a".to_string(); REDUNDANT_LABEL_COVERAGE_MIN_CANDIDATES - 1];
assert!(
!storage
.redundant_default_visible_single_label_filter(
&global_label_filters,
Some(&narrow_candidate_ids),
)
.unwrap()
);
let no_label_list_ids = issue_ids(storage.list_issues(&no_label_filters).unwrap());
let global_label_list_ids = issue_ids(storage.list_issues(&global_label_filters).unwrap());
assert_eq!(global_label_list_ids, no_label_list_ids);
let no_label_search_ids =
issue_ids(storage.search_issues("needle", &no_label_filters).unwrap());
let global_label_search_ids = issue_ids(
storage
.search_issues("needle", &global_label_filters)
.unwrap(),
);
assert_eq!(global_label_search_ids, no_label_search_ids);
let include_deferred_global_label_ids = issue_ids(
storage
.search_issues(
"needle",
&ListFilters {
include_deferred: true,
labels: Some(vec!["global".to_string()]),
..ListFilters::default()
},
)
.unwrap(),
);
assert_eq!(
include_deferred_global_label_ids,
vec!["bd-s-cover-a", "bd-s-cover-b"]
);
}
#[test]
fn test_search_issues_for_command_output_matches_text_fields() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let mut description_match = make_issue(
"bd-search-proj-a",
"Command projection alpha",
Status::Open,
1,
Some("agent-a"),
t1,
None,
);
description_match.description =
Some("description carries projection needle context".to_string());
description_match.design = Some("unused design".repeat(512));
description_match.acceptance_criteria = Some("unused ac".repeat(512));
description_match.notes = Some("unused notes".repeat(512));
description_match.owner = Some("owner".to_string());
description_match.sender = Some("cli".to_string());
let title_match = make_issue(
"bd-search-proj-b",
"Projection needle title",
Status::Open,
2,
Some("agent-b"),
t1 + chrono::Duration::minutes(1),
None,
);
storage.create_issue(&description_match, "tester").unwrap();
storage.create_issue(&title_match, "tester").unwrap();
let filters = ListFilters {
limit: Some(0),
..ListFilters::default()
};
let full = storage
.search_issues("projection needle", &filters)
.unwrap();
let projected = storage
.search_issues_for_command_output("projection needle", &filters)
.unwrap();
let full_summary = full
.iter()
.map(|issue| {
(
issue.id.as_str(),
issue.title.as_str(),
issue.description.as_deref(),
issue.status.clone(),
issue.priority,
issue.issue_type.clone(),
issue.assignee.as_deref(),
issue.created_at,
issue.updated_at,
)
})
.collect::<Vec<_>>();
let projected_summary = projected
.iter()
.map(|issue| {
(
issue.id.as_str(),
issue.title.as_str(),
issue.description.as_deref(),
issue.status.clone(),
issue.priority,
issue.issue_type.clone(),
issue.assignee.as_deref(),
issue.created_at,
issue.updated_at,
)
})
.collect::<Vec<_>>();
assert_eq!(projected_summary, full_summary);
let full_lines = full
.iter()
.map(|issue| format_issue_line_with(issue, TextFormatOptions::plain()))
.collect::<Vec<_>>();
let projected_lines = projected
.iter()
.map(|issue| format_issue_line_with(issue, TextFormatOptions::plain()))
.collect::<Vec<_>>();
assert_eq!(projected_lines, full_lines);
let projected_description_match = projected
.iter()
.find(|issue| issue.id == "bd-search-proj-a")
.unwrap();
assert!(projected_description_match.description.is_some());
assert!(projected_description_match.design.is_none());
assert!(projected_description_match.acceptance_criteria.is_none());
assert!(projected_description_match.notes.is_none());
assert!(projected_description_match.owner.is_none());
assert!(projected_description_match.sender.is_none());
}
#[test]
fn test_search_issues_respects_include_deferred_flag() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let open_issue = make_issue(
"bd-s-open",
"authentication flow update",
Status::Open,
2,
None,
t1,
None,
);
let deferred_issue = make_issue(
"bd-s-deferred",
"authentication flow deferred follow-up",
Status::Deferred,
2,
None,
t1,
None,
);
storage.create_issue(&open_issue, "tester").unwrap();
storage.create_issue(&deferred_issue, "tester").unwrap();
let filters = ListFilters {
include_deferred: false,
..ListFilters::default()
};
let results = storage.search_issues("authentication", &filters).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "bd-s-open");
}
#[test]
fn test_search_issues_orders_by_updated() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
let t3 = Utc.with_ymd_and_hms(2025, 9, 3, 0, 0, 0).unwrap();
let older_updated = make_issue(
"bd-s-sort-a",
"authentication alpha",
Status::Open,
2,
None,
t3,
None,
);
let newer_updated = make_issue(
"bd-s-sort-b",
"authentication beta",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&older_updated, "tester").unwrap();
storage.create_issue(&newer_updated, "tester").unwrap();
storage
.execute_test_sql(&format!(
"UPDATE issues SET updated_at = '{}' WHERE id = 'bd-s-sort-a';\n\
UPDATE issues SET updated_at = '{}' WHERE id = 'bd-s-sort-b';",
t1.to_rfc3339(),
t3.to_rfc3339()
))
.unwrap();
let results = storage
.search_issues(
"authentication",
&ListFilters {
sort: Some("updated".to_string()),
..ListFilters::default()
},
)
.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].id, "bd-s-sort-b");
}
#[test]
fn test_search_issues_applies_offset() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 9, 1, 0, 0, 0).unwrap();
for (idx, id) in ["bd-s-page-a", "bd-s-page-b", "bd-s-page-c"]
.into_iter()
.enumerate()
{
let issue = make_issue(
id,
&format!("authentication page {idx}"),
Status::Open,
i32::try_from(idx + 1).unwrap(),
None,
t1 + chrono::Duration::minutes(i64::try_from(idx).unwrap()),
None,
);
storage.create_issue(&issue, "tester").unwrap();
}
let results = storage
.search_issues(
"authentication",
&ListFilters {
limit: Some(1),
offset: Some(1),
..ListFilters::default()
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "bd-s-page-b");
let all_after_offset = storage
.search_issues(
"authentication",
&ListFilters {
limit: Some(0),
offset: Some(2),
..ListFilters::default()
},
)
.unwrap();
let ids: Vec<_> = all_after_offset
.iter()
.map(|issue| issue.id.as_str())
.collect();
assert_eq!(ids, vec!["bd-s-page-c"]);
}
#[test]
fn test_search_issues_limit_uses_id_tiebreaker() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2025, 9, 4, 0, 0, 0).unwrap();
for issue in [
make_issue(
"bd-search-b",
"authentication tie b",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-search-a",
"authentication tie a",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-search-c",
"authentication later",
Status::Open,
2,
None,
created_at - chrono::Duration::days(1),
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
let results = storage
.search_issues(
"authentication",
&ListFilters {
limit: Some(2),
..ListFilters::default()
},
)
.unwrap();
let ids: Vec<_> = results.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(ids, vec!["bd-search-a", "bd-search-b"]);
}
#[test]
fn test_search_issues_default_visible_limited_page_matches_generic_order() {
let mut storage = SqliteStorage::open_memory().unwrap();
let base = Utc.with_ymd_and_hms(2025, 9, 4, 0, 0, 0).unwrap();
let mut closed = make_issue(
"bd-search-closed",
"needle closed",
Status::Closed,
0,
None,
base + chrono::Duration::minutes(10),
None,
);
closed.closed_at = Some(base + chrono::Duration::minutes(11));
let mut template = make_issue(
"bd-search-template",
"needle template",
Status::Open,
0,
None,
base + chrono::Duration::minutes(9),
None,
);
template.is_template = true;
for issue in [
make_issue(
"bd-search-p1",
"needle p1",
Status::Open,
1,
None,
base + chrono::Duration::minutes(4),
None,
),
make_issue(
"bd-search-p0-old",
"needle p0 old",
Status::Open,
0,
None,
base + chrono::Duration::minutes(1),
None,
),
make_issue(
"bd-search-deferred",
"needle deferred",
Status::Deferred,
0,
None,
base + chrono::Duration::minutes(3),
None,
),
make_issue(
"bd-search-p0-new",
"needle p0 new",
Status::Open,
0,
None,
base + chrono::Duration::minutes(2),
None,
),
make_issue(
"bd-search-nomatch",
"other",
Status::Open,
0,
None,
base + chrono::Duration::minutes(5),
None,
),
closed,
template,
] {
storage.create_issue(&issue, "tester").unwrap();
}
let fast_filters = ListFilters {
include_deferred: true,
limit: Some(3),
..ListFilters::default()
};
let generic_filters = ListFilters {
include_deferred: true,
limit: Some(3),
sort: Some("priority".to_string()),
..ListFilters::default()
};
let fast_ids = issue_ids(storage.search_issues("needle", &fast_filters).unwrap());
let generic_ids = issue_ids(storage.search_issues("needle", &generic_filters).unwrap());
assert_eq!(fast_ids, generic_ids);
assert_eq!(
fast_ids,
vec!["bd-search-deferred", "bd-search-p0-new", "bd-search-p0-old",]
);
let fast_no_match = storage.search_issues("absent", &fast_filters).unwrap();
let generic_no_match = storage.search_issues("absent", &generic_filters).unwrap();
assert_eq!(fast_no_match, generic_no_match);
assert!(fast_no_match.is_empty());
}
#[test]
fn test_search_issues_filter_by_updated_date() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
let old = now - chrono::Duration::days(10);
let older = now - chrono::Duration::days(20);
let issue1 = make_issue(
"bd-search-old",
"authentication old",
Status::Open,
2,
None,
old,
None,
);
let issue2 = make_issue(
"bd-search-older",
"authentication older",
Status::Open,
2,
None,
older,
None,
);
let issue3 = make_issue(
"bd-search-new",
"authentication new",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
storage.create_issue(&issue3, "tester").unwrap();
let mut filters = ListFilters {
updated_before: Some(old),
..Default::default()
};
let issues = storage.search_issues("authentication", &filters).unwrap();
let ids: Vec<_> = issues.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(issues.len(), 2);
assert!(ids.contains(&"bd-search-old"));
assert!(ids.contains(&"bd-search-older"));
assert!(!ids.contains(&"bd-search-new"));
filters.updated_before = None;
filters.updated_after = Some(old);
let issues = storage.search_issues("authentication", &filters).unwrap();
let ids: Vec<_> = issues.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(issues.len(), 2);
assert!(ids.contains(&"bd-search-old"));
assert!(ids.contains(&"bd-search-new"));
assert!(!ids.contains(&"bd-search-older"));
}
#[test]
fn test_list_issues_filter_by_updated_date() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
let old = now - chrono::Duration::days(10);
let older = now - chrono::Duration::days(20);
let issue1 = make_issue("bd-old", "Old issue", Status::Open, 2, None, old, None);
let issue2 = make_issue(
"bd-older",
"Older issue",
Status::Open,
2,
None,
older,
None,
);
let issue3 = make_issue("bd-new", "New issue", Status::Open, 2, None, now, None);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
storage.create_issue(&issue3, "tester").unwrap();
// Filter updated_before 'old' (inclusive? SQL uses <=)
// If we use 'old', issue1 matches. issue2 matches. issue3 does not.
let mut filters = ListFilters {
updated_before: Some(old),
..Default::default()
};
let issues = storage.list_issues(&filters).unwrap();
// Should contain bd-old and bd-older
assert_eq!(issues.len(), 2);
let ids: Vec<_> = issues.iter().map(|i| i.id.as_str()).collect();
assert!(ids.contains(&"bd-old"));
assert!(ids.contains(&"bd-older"));
assert!(!ids.contains(&"bd-new"));
// Filter updated_after 'old'
filters.updated_before = None;
filters.updated_after = Some(old);
let issues = storage.list_issues(&filters).unwrap();
// Should contain bd-old and bd-new
assert_eq!(issues.len(), 2);
let ids: Vec<_> = issues.iter().map(|i| i.id.as_str()).collect();
assert!(ids.contains(&"bd-old"));
assert!(ids.contains(&"bd-new"));
assert!(!ids.contains(&"bd-older"));
}
#[test]
fn test_list_issues_filter_by_labels() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let issue1 = make_issue("bd-l1", "Issue with label", Status::Open, 2, None, t1, None);
let issue2 = make_issue(
"bd-l2",
"Issue without label",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
// Add label to issue1
storage.add_label("bd-l1", "test-label", "tester").unwrap();
// Filter by label
let filters = ListFilters {
labels: Some(vec!["test-label".to_string()]),
..Default::default()
};
let issues = storage.list_issues(&filters).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "bd-l1");
}
#[test]
fn test_list_issues_filter_by_multiple_labels_uses_and_logic() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let issue1 = make_issue("bd-l3", "Core only", Status::Open, 2, None, t1, None);
let issue2 = make_issue(
"bd-l4",
"Core and frontend",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&issue1, "tester").unwrap();
storage.create_issue(&issue2, "tester").unwrap();
storage.add_label("bd-l3", "core", "tester").unwrap();
storage.add_label("bd-l4", "core", "tester").unwrap();
storage.add_label("bd-l4", "frontend", "tester").unwrap();
let filters = ListFilters {
labels: Some(vec!["core".to_string(), "frontend".to_string()]),
..Default::default()
};
let issues = storage.list_issues(&filters).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "bd-l4");
let duplicate_filters = ListFilters {
labels: Some(vec![
"core".to_string(),
"frontend".to_string(),
"frontend".to_string(),
]),
..Default::default()
};
let duplicate_issues = storage.list_issues(&duplicate_filters).unwrap();
assert_eq!(duplicate_issues.len(), 1);
assert_eq!(duplicate_issues[0].id, "bd-l4");
assert_eq!(
storage
.count_issues_with_filters(&duplicate_filters)
.unwrap(),
1
);
}
#[test]
fn test_list_issues_combined_type_and_label_filters() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let task_issue = make_issue("bd-l5", "Core task", Status::Open, 1, None, t1, None);
let mut feature_issue =
make_issue("bd-l6", "Core feature", Status::Open, 1, None, t1, None);
feature_issue.issue_type = IssueType::Feature;
storage.create_issue(&task_issue, "tester").unwrap();
storage.create_issue(&feature_issue, "tester").unwrap();
storage.add_label("bd-l5", "core", "tester").unwrap();
storage.add_label("bd-l6", "core", "tester").unwrap();
let filters = ListFilters {
types: Some(vec![IssueType::Task]),
labels: Some(vec!["core".to_string()]),
..Default::default()
};
let issues = storage.list_issues(&filters).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "bd-l5");
assert_eq!(storage.count_issues_with_filters(&filters).unwrap(), 1);
}
#[test]
fn test_list_issues_materialized_label_candidates_match_filtered_fallback() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
for id in ["bd-lj1", "bd-lj2", "bd-lj3", "bd-lj4"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
for (issue_id, labels) in [
("bd-lj1", ["export", "lane-00", "swarm"].as_slice()),
("bd-lj2", ["export", "lane-01"].as_slice()),
("bd-lj3", ["lane-00", "swarm"].as_slice()),
("bd-lj4", ["export", "swarm"].as_slice()),
] {
for label in labels {
storage.add_label(issue_id, label, "tester").unwrap();
}
}
let fast_filters = ListFilters {
labels: Some(vec!["export".to_string()]),
limit: Some(2),
..Default::default()
};
let fallback_filters = ListFilters {
types: Some(vec![IssueType::Task]),
..fast_filters.clone()
};
let fast_ids: Vec<_> = storage
.list_issues(&fast_filters)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
let fallback_ids: Vec<_> = storage
.list_issues(&fallback_filters)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert_eq!(fast_ids, vec!["bd-lj1", "bd-lj2"]);
assert_eq!(fast_ids, fallback_ids);
assert_eq!(
storage.count_issues_with_filters(&fast_filters).unwrap(),
storage
.count_issues_with_filters(&fallback_filters)
.unwrap()
);
assert_eq!(storage.count_issues_with_filters(&fast_filters).unwrap(), 3);
}
#[test]
fn test_blocked_cache_handles_quotes_in_ids() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let issue = make_issue("bd-x1", "Blocked", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
// Add a dependency on an ID containing a quote, as could exist in a
// legacy/corrupt database. `create_issue` now rejects invalid IDs, so
// seed the low-level row directly through the import upsert path.
let tricky_id = "bd-q\"ote";
let tricky_issue = make_issue(tricky_id, "Tricky", Status::Open, 2, None, t1, None);
storage.upsert_issue_for_import(&tricky_issue).unwrap();
storage
.add_dependency("bd-x1", tricky_id, "blocks", "tester")
.unwrap();
// Cache should be rebuilt and handle the quote correctly
// (rebuild happens automatically on add_dependency via mutation context)
// Verify we can read it back without error
let blocked = storage.get_blocked_issues().unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].0.id, "bd-x1");
let blockers = &blocked[0].1;
assert_eq!(blockers.len(), 1);
// ID + ":open" (since the tricky issue is open)
assert_eq!(blockers[0], "bd-q\"ote:open");
}
#[test]
fn test_get_ready_issues_filters_by_labels() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let i1 = make_issue("bd-1", "A", Status::Open, 2, None, t1, None);
let i2 = make_issue("bd-2", "B", Status::Open, 2, None, t1, None);
let i3 = make_issue("bd-3", "C", Status::Open, 2, None, t1, None);
storage.create_issue(&i1, "tester").unwrap();
storage.create_issue(&i2, "tester").unwrap();
storage.create_issue(&i3, "tester").unwrap();
storage.add_label("bd-1", "backend", "tester").unwrap();
storage.add_label("bd-1", "urgent", "tester").unwrap();
storage.add_label("bd-2", "backend", "tester").unwrap();
// bd-3 has no labels
// Filter AND: backend + urgent
let filters_and = ReadyFilters {
labels_and: vec!["backend".to_string(), "urgent".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&filters_and, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].id, "bd-1");
// Filter OR: urgent
let filters_or = ReadyFilters {
labels_or: vec!["urgent".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&filters_or, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].id, "bd-1");
// Filter OR: backend (should get 1 and 2)
let filters_or_backend = ReadyFilters {
labels_or: vec!["backend".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&filters_or_backend, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(res.len(), 2);
}
#[test]
fn test_ready_default_group_is_open_only() {
// #354: with no ready_statuses configured, the query behaves exactly as
// before — only `open` issues surface, `rework`/`in_progress` do not.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
storage
.create_issue(
&make_issue("bd-open", "Open", Status::Open, 2, None, t1, None),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue(
"bd-rework",
"Rework",
Status::Custom("rework".to_string()),
2,
None,
t1,
None,
),
"tester",
)
.unwrap();
let filters = ReadyFilters::default();
let res = storage
.get_ready_issues(&filters, ReadySortPolicy::Oldest)
.unwrap();
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["bd-open"], "default group must be [open] only");
}
#[test]
fn test_ready_configured_group_surfaces_rework() {
// #354: a configured ready group [open, rework] surfaces rework items
// while preserving each issue's actual status.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
storage
.create_issue(
&make_issue("bd-open", "Open", Status::Open, 2, None, t1, None),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue(
"bd-rework",
"Rework",
Status::Custom("rework".to_string()),
2,
None,
t1,
None,
),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue(
"bd-inprog",
"InProgress",
Status::InProgress,
2,
None,
t1,
None,
),
"tester",
)
.unwrap();
let filters = ReadyFilters {
ready_statuses: vec!["open".to_string(), "rework".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&filters, ReadySortPolicy::Oldest)
.unwrap();
let mut ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["bd-open", "bd-rework"]);
// in_progress stays out; statuses are preserved.
let rework = res.iter().find(|i| i.id == "bd-rework").unwrap();
assert_eq!(rework.status.as_str(), "rework");
}
#[test]
fn test_ready_custom_only_group_surfaces_candidates_in_every_projection() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc::now();
storage
.create_issue(
&make_issue(
"bd-rework-only",
"Rework only",
Status::Custom("rework".to_string()),
2,
None,
now,
None,
),
"tester",
)
.unwrap();
let filters = ReadyFilters {
ready_statuses: vec!["rework".to_string()],
..Default::default()
};
for (projection, name) in [
(ReadyIssueProjection::Full, "full"),
(ReadyIssueProjection::Command, "command"),
(ReadyIssueProjection::Summary, "summary"),
] {
let issues = storage
.get_ready_issues_with_projection(&filters, ReadySortPolicy::Oldest, projection)
.unwrap();
assert_eq!(issues.len(), 1, "{name} projection lost custom-only work");
assert_eq!(issues[0].id, "bd-rework-only");
assert_eq!(issues[0].status.as_str(), "rework");
}
}
#[test]
fn test_ready_configured_group_still_gates_defer_until() {
// #354: a non-deferred configured member with a future defer_until is
// still time-gated out unless --include-deferred is set.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let future = t1 + chrono::Duration::days(7);
storage
.create_issue(
&make_issue(
"bd-rework-deferred",
"ReworkDeferred",
Status::Custom("rework".to_string()),
2,
None,
t1,
Some(future),
),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue("bd-open", "Open", Status::Open, 2, None, t1, None),
"tester",
)
.unwrap();
let filters = ReadyFilters {
ready_statuses: vec!["open".to_string(), "rework".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&filters, ReadySortPolicy::Oldest)
.unwrap();
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec!["bd-open"],
"future defer_until must gate the rework member out"
);
// With --include-deferred, the gate drops and the rework member returns.
let filters_deferred = ReadyFilters {
ready_statuses: vec!["open".to_string(), "rework".to_string()],
include_deferred: true,
..Default::default()
};
let res = storage
.get_ready_issues(&filters_deferred, ReadySortPolicy::Oldest)
.unwrap();
let mut ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["bd-open", "bd-rework-deferred"]);
}
#[test]
fn test_ready_include_deferred_no_double_count_when_group_lists_deferred() {
// #354: --include-deferred folds in `deferred`, but must not double-count
// it (or error) when the configured group already lists `deferred`.
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
storage
.create_issue(
&make_issue(
"bd-deferred",
"Deferred",
Status::Deferred,
2,
None,
t1,
None,
),
"tester",
)
.unwrap();
storage
.create_issue(
&make_issue("bd-open", "Open", Status::Open, 2, None, t1, None),
"tester",
)
.unwrap();
let filters = ReadyFilters {
ready_statuses: vec!["open".to_string(), "deferred".to_string()],
include_deferred: true,
..Default::default()
};
let res = storage
.get_ready_issues(&filters, ReadySortPolicy::Oldest)
.unwrap();
let mut ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
ids.sort_unstable();
// Exactly one row per id — no duplicate `bd-deferred`.
assert_eq!(ids, vec!["bd-deferred", "bd-open"]);
}
#[test]
fn test_get_ready_issues_filters_by_parent() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
// Create parent epic. `bd-epic` and `bd-epic.1` are typed as Epic
// so the epic-rollup "parent blocked by open children" rule applies
// to them; that is what the later "only non-blocked descendants are
// ready" assertion exercises. Non-epic parents intentionally do
// NOT inherit a child-open blocker (see
// `parent_child_transitive_blocking` in
// `tests/storage_blocked_cache.rs`), so typing these as Task would
// make every parent-child node trivially ready, defeating the
// point of the recursive-ready test.
let mut parent = make_issue("bd-epic", "Parent Epic", Status::Open, 1, None, t1, None);
parent.issue_type = IssueType::Epic;
storage.create_issue(&parent, "tester").unwrap();
// Create direct children of the epic. `bd-epic.1` is itself an
// epic so it can be rolled-up-blocked by its grandchild below.
let mut child1 = make_issue("bd-epic.1", "Child 1", Status::Open, 2, None, t1, None);
child1.issue_type = IssueType::Epic;
let child2 = make_issue("bd-epic.2", "Child 2", Status::Open, 2, None, t1, None);
storage.create_issue(&child1, "tester").unwrap();
storage.create_issue(&child2, "tester").unwrap();
// Create unrelated issue (not a child of the epic)
let unrelated = make_issue("bd-other", "Unrelated", Status::Open, 2, None, t1, None);
storage.create_issue(&unrelated, "tester").unwrap();
// Add parent-child dependencies (no grandchild yet)
storage
.add_dependency("bd-epic.1", "bd-epic", "parent-child", "tester")
.unwrap();
storage
.add_dependency("bd-epic.2", "bd-epic", "parent-child", "tester")
.unwrap();
// Test: --parent bd-epic (non-recursive) should return only direct children
let filters_direct = ReadyFilters {
parent: Some("bd-epic".to_string()),
recursive: false,
..Default::default()
};
let res = storage
.get_ready_issues(&filters_direct, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(
res.len(),
2,
"Non-recursive should return only direct children"
);
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert!(ids.contains(&"bd-epic.1"), "Should contain child1");
assert!(ids.contains(&"bd-epic.2"), "Should contain child2");
// Now create grandchild and its dependency for recursive test
let grandchild = make_issue("bd-epic.1.1", "Grandchild", Status::Open, 2, None, t1, None);
storage.create_issue(&grandchild, "tester").unwrap();
storage
.add_dependency("bd-epic.1.1", "bd-epic.1", "parent-child", "tester")
.unwrap();
// Test: --parent bd-epic --recursive should return all non-blocked descendants
// Note: bd-epic.1 is now blocked by its open child bd-epic.1.1 (blocked-cache
// semantics), so only bd-epic.2 and bd-epic.1.1 are "ready".
let filters_recursive = ReadyFilters {
parent: Some("bd-epic".to_string()),
recursive: true,
..Default::default()
};
let res = storage
.get_ready_issues(&filters_recursive, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(
res.len(),
2,
"Recursive should return non-blocked descendants"
);
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert!(ids.contains(&"bd-epic.2"), "Should contain child2");
assert!(ids.contains(&"bd-epic.1.1"), "Should contain grandchild");
assert!(
!ids.contains(&"bd-epic"),
"Should NOT contain the parent itself"
);
assert!(
!ids.contains(&"bd-other"),
"Should NOT contain unrelated issue"
);
// Test: --parent with non-existent parent should return empty
let filters_nonexistent = ReadyFilters {
parent: Some("bd-nonexistent".to_string()),
recursive: false,
..Default::default()
};
let res = storage
.get_ready_issues(&filters_nonexistent, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(res.len(), 0, "Non-existent parent should return empty");
}
#[test]
fn test_get_ready_issues_oversized_parent_membership_preserves_sort_and_limit() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 8, 26, 0, 0, 0).unwrap();
let parent = make_issue(
"bd-wide-parent",
"Wide parent",
Status::Open,
2,
None,
created_at,
None,
);
let unrelated = make_issue(
"bd-wide-unrelated",
"Unrelated",
Status::Open,
2,
None,
created_at,
None,
);
storage.create_issue(&parent, "tester").unwrap();
storage.create_issue(&unrelated, "tester").unwrap();
// More than SQLite's total bound-variable budget. The historical
// implementation emitted multiple `id IN (...)` chunks in one
// statement, but SQLite still counted every placeholder together.
let child_count = SQLITE_VAR_LIMIT + 101;
storage.conn.execute("BEGIN IMMEDIATE").unwrap();
for index in 0..child_count {
let id = format!("bd-wide-child-{index:04}");
let offset = i64::try_from(index).expect("child index fits in i64");
let timestamp = (created_at + chrono::Duration::seconds(offset)).to_rfc3339();
storage
.conn
.execute_with_params(
"INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at) \
VALUES (?, ?, 'open', 2, 'task', ?, ?)",
&[
SqliteValue::from(id.as_str()),
SqliteValue::from(id.as_str()),
SqliteValue::from(timestamp.as_str()),
SqliteValue::from(timestamp.as_str()),
],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) \
VALUES (?, 'bd-wide-parent', 'parent-child', ?, 'tester')",
&[
SqliteValue::from(id.as_str()),
SqliteValue::from(timestamp.as_str()),
],
)
.unwrap();
}
storage.conn.execute("COMMIT").unwrap();
let filters = ReadyFilters {
parent: Some("bd-wide-parent".to_string()),
limit: Some(3),
..Default::default()
};
let expected = vec![
"bd-wide-child-0000".to_string(),
"bd-wide-child-0001".to_string(),
"bd-wide-child-0002".to_string(),
];
for (cache_state, stale) in [("healthy", false), ("stale", true)] {
if stale {
storage.mark_blocked_cache_stale().unwrap();
} else {
storage.rebuild_blocked_cache(true).unwrap();
}
for (projection, name) in [
(ReadyIssueProjection::Full, "full"),
(ReadyIssueProjection::Command, "command"),
(ReadyIssueProjection::Summary, "summary"),
] {
let issues = storage
.get_ready_issues_with_projection(&filters, ReadySortPolicy::Oldest, projection)
.unwrap();
let ids = issues.into_iter().map(|issue| issue.id).collect::<Vec<_>>();
assert_eq!(
ids, expected,
"{cache_state} cache, {name} projection changed sort/limit semantics"
);
}
}
}
/// Regression: `--parent` combined with `--label` must return their
/// intersection, not an empty set (#307). Also covers `--parent --recursive
/// --label` (#308 / #307 interaction).
#[test]
fn test_get_ready_issues_parent_combined_with_label() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
let mut parent = make_issue("bd-epic", "Parent Epic", Status::Open, 1, None, t1, None);
parent.issue_type = IssueType::Epic;
storage.create_issue(&parent, "tester").unwrap();
// Two labelled direct children + one unlabelled direct child.
let child1 = make_issue("bd-epic.1", "Child 1", Status::Open, 2, None, t1, None);
let child2 = make_issue("bd-epic.2", "Child 2", Status::Open, 2, None, t1, None);
let child3 = make_issue("bd-epic.3", "Child 3", Status::Open, 2, None, t1, None);
storage.create_issue(&child1, "tester").unwrap();
storage.create_issue(&child2, "tester").unwrap();
storage.create_issue(&child3, "tester").unwrap();
for id in ["bd-epic.1", "bd-epic.2", "bd-epic.3"] {
storage
.add_dependency(id, "bd-epic", "parent-child", "tester")
.unwrap();
}
storage
.add_label("bd-epic.1", "mini-safe", "tester")
.unwrap();
storage
.add_label("bd-epic.2", "mini-safe", "tester")
.unwrap();
// A labelled descendant under bd-epic.1 (for the recursive case).
let grandchild = make_issue("bd-epic.1.1", "Grandchild", Status::Open, 2, None, t1, None);
storage.create_issue(&grandchild, "tester").unwrap();
storage
.add_dependency("bd-epic.1.1", "bd-epic.1", "parent-child", "tester")
.unwrap();
storage
.add_label("bd-epic.1.1", "mini-safe", "tester")
.unwrap();
// --parent alone: all three direct children are ready (non-epic parents
// do not inherit a child-open blocker).
let parent_only = ReadyFilters {
parent: Some("bd-epic".to_string()),
..Default::default()
};
let res = storage
.get_ready_issues(&parent_only, ReadySortPolicy::Oldest)
.unwrap();
assert_eq!(
res.len(),
3,
"parent-only should return three direct children"
);
// --parent + --label: intersection = the two labelled direct children.
let parent_label = ReadyFilters {
parent: Some("bd-epic".to_string()),
labels_and: vec!["mini-safe".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&parent_label, ReadySortPolicy::Oldest)
.unwrap();
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
res.len(),
2,
"parent+label must return the AND-intersection, got {ids:?}"
);
assert!(ids.contains(&"bd-epic.1"));
assert!(ids.contains(&"bd-epic.2"));
assert!(!ids.contains(&"bd-epic.3"), "unlabelled child excluded");
// --parent + --recursive + --label: includes the labelled grandchild.
let parent_recursive_label = ReadyFilters {
parent: Some("bd-epic".to_string()),
recursive: true,
labels_and: vec!["mini-safe".to_string()],
..Default::default()
};
let res = storage
.get_ready_issues(&parent_recursive_label, ReadySortPolicy::Oldest)
.unwrap();
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert!(
ids.contains(&"bd-epic.1.1"),
"recursive+label must reach labelled grandchild, got {ids:?}"
);
assert!(ids.contains(&"bd-epic.2"));
}
/// Regression: `--parent --recursive` terminates even when the parent-child
/// graph contains a cycle, instead of hanging on an unbounded walk (#308).
#[test]
fn test_get_ready_issues_recursive_parent_cycle_terminates() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
for id in ["cyc-a", "cyc-b", "cyc-c"] {
let issue = make_issue(id, id, Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
}
// a -> b -> c -> a (cycle through parent-child edges).
storage
.add_dependency("cyc-b", "cyc-a", "parent-child", "tester")
.unwrap();
storage
.add_dependency("cyc-c", "cyc-b", "parent-child", "tester")
.unwrap();
// The edge that closes the a -> b -> c -> a cycle. `add_dependency` now
// correctly REFUSES to create a parent-child cycle, so a genuine cycle
// can only enter the store via import/legacy data. Insert the closing
// row straight into the table (bypassing the guard) to exercise the
// recursive-BFS termination path against real cyclic data.
storage
.conn
.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) \
VALUES (?, ?, ?, ?, ?)",
&[
SqliteValue::from("cyc-a"),
SqliteValue::from("cyc-c"),
SqliteValue::from("parent-child"),
SqliteValue::from(Utc::now().to_rfc3339()),
SqliteValue::from("tester"),
],
)
.unwrap();
let filters = ReadyFilters {
parent: Some("cyc-a".to_string()),
recursive: true,
..Default::default()
};
// The visited-set BFS must terminate; the exact membership is whatever
// the readiness rules allow, but the call must return without hanging.
let res = storage
.get_ready_issues(&filters, ReadySortPolicy::Oldest)
.unwrap();
let ids: Vec<&str> = res.iter().map(|i| i.id.as_str()).collect();
assert!(
!ids.contains(&"cyc-a"),
"parent itself must not appear, got {ids:?}"
);
}
#[test]
fn test_get_ready_issues_treats_null_legacy_flags_as_false() {
let conn = Connection::open(":memory:").unwrap();
crate::storage::schema::execute_batch(
&conn,
r"
CREATE TABLE issues (
id TEXT PRIMARY KEY,
content_hash TEXT,
title TEXT NOT NULL,
description TEXT,
design TEXT,
acceptance_criteria TEXT,
notes TEXT,
status TEXT NOT NULL,
priority INTEGER NOT NULL,
issue_type TEXT NOT NULL,
assignee TEXT,
owner TEXT,
estimated_minutes INTEGER,
created_at DATETIME NOT NULL,
created_by TEXT,
updated_at DATETIME NOT NULL,
closed_at DATETIME,
close_reason TEXT,
closed_by_session TEXT,
due_at DATETIME,
defer_until DATETIME,
external_ref TEXT,
source_system TEXT,
source_repo TEXT,
deleted_at DATETIME,
deleted_by TEXT,
delete_reason TEXT,
original_type TEXT,
compaction_level INTEGER,
compacted_at DATETIME,
compacted_at_commit TEXT,
original_size INTEGER,
sender TEXT,
ephemeral INTEGER,
pinned INTEGER,
is_template INTEGER,
source_repo_path TEXT,
agent_context TEXT
);
CREATE TABLE blocked_issues_cache (
issue_id TEXT PRIMARY KEY,
blocked_by TEXT NOT NULL,
blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE metadata (
key TEXT NOT NULL,
value TEXT NOT NULL
);
",
)
.unwrap();
let storage = SqliteStorage {
conn,
write_authority: None,
mutation_count: 0,
temp_db_path: None,
pending_event_attribution: None,
opener_lease: None,
workflow_capacity_policy: crate::close_policy::CapacityPolicy::default(),
workflow_transition_policy: crate::close_policy::Workflow::default(),
last_capacity_warnings: Vec::new(),
};
let timestamp = Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap();
let stamp = timestamp.to_rfc3339();
storage
.conn
.execute_with_params(
r"
INSERT INTO issues (
id, title, status, priority, issue_type, created_at, updated_at,
ephemeral, pinned, is_template
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
",
&[
SqliteValue::from("bd-legacy-ready"),
SqliteValue::from("Legacy ready issue"),
SqliteValue::from("open"),
SqliteValue::from(2_i64),
SqliteValue::from("task"),
SqliteValue::from(stamp.as_str()),
SqliteValue::from(stamp.as_str()),
],
)
.unwrap();
let ready = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Priority)
.unwrap();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, "bd-legacy-ready");
}
#[test]
fn test_get_ready_issues_skips_stale_cache_work_when_no_candidate_status() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap();
let mut closed = make_issue(
"bd-closed",
"Closed issue",
Status::Closed,
2,
None,
now,
None,
);
closed.closed_at = Some(now);
storage.create_issue(&closed, "tester").unwrap();
storage.mark_blocked_cache_stale().unwrap();
let ready = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Priority)
.unwrap();
assert!(ready.is_empty());
assert!(
storage.blocked_cache_marked_stale().unwrap(),
"read-only ready query must not refresh or clear stale blocked-cache metadata"
);
}
#[test]
fn test_get_ready_issues_hybrid_sort_and_limit() {
let mut storage = SqliteStorage::open_memory().unwrap();
let base = Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap();
for issue in [
make_issue("bd-low-old", "Low old", Status::Open, 4, None, base, None),
make_issue(
"bd-high-old",
"High old",
Status::Open,
1,
None,
base + chrono::Duration::seconds(1),
None,
),
make_issue(
"bd-critical-new",
"Critical new",
Status::Open,
0,
None,
base + chrono::Duration::seconds(2),
None,
),
make_issue(
"bd-low-new",
"Low new",
Status::Open,
2,
None,
base + chrono::Duration::seconds(3),
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
let ids: Vec<String> = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Hybrid)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert_eq!(
ids,
["bd-high-old", "bd-critical-new", "bd-low-old", "bd-low-new"]
);
let limited_ids: Vec<String> = storage
.get_ready_issues(
&ReadyFilters {
limit: Some(2),
..ReadyFilters::default()
},
ReadySortPolicy::Hybrid,
)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert_eq!(limited_ids, ["bd-high-old", "bd-critical-new"]);
let fallback_limited_ids: Vec<String> = storage
.get_ready_issues(
&ReadyFilters {
limit: Some(3),
..ReadyFilters::default()
},
ReadySortPolicy::Hybrid,
)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert_eq!(
fallback_limited_ids,
["bd-high-old", "bd-critical-new", "bd-low-old"]
);
}
#[test]
fn test_get_ready_issues_limited_sql_sort_uses_id_tiebreaker() {
let mut storage = SqliteStorage::open_memory().unwrap();
let created_at = Utc.with_ymd_and_hms(2026, 3, 13, 0, 0, 0).unwrap();
for issue in [
make_issue(
"bd-tie-b",
"Tie inserted first",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-tie-a",
"Tie inserted second",
Status::Open,
1,
None,
created_at,
None,
),
make_issue(
"bd-low",
"Low priority",
Status::Open,
3,
None,
created_at + chrono::Duration::seconds(1),
None,
),
] {
storage.create_issue(&issue, "tester").unwrap();
}
let ids: Vec<String> = storage
.get_ready_issues(
&ReadyFilters {
limit: Some(2),
..ReadyFilters::default()
},
ReadySortPolicy::Hybrid,
)
.unwrap()
.into_iter()
.map(|issue| issue.id)
.collect();
assert_eq!(ids, ["bd-tie-a", "bd-tie-b"]);
}
#[test]
fn test_get_ready_issues_excludes_in_progress() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc::now();
// Create an open issue (should appear in ready)
let open_issue = make_issue("bd-open", "Open Issue", Status::Open, 2, None, t1, None);
storage.create_issue(&open_issue, "tester").unwrap();
// Create an in_progress issue (should NOT appear in ready)
let ip_issue = make_issue(
"bd-inprogress",
"In Progress Issue",
Status::InProgress,
1,
None,
t1,
None,
);
storage.create_issue(&ip_issue, "tester").unwrap();
let ready = storage
.get_ready_issues(&ReadyFilters::default(), ReadySortPolicy::Priority)
.unwrap();
// Only the open issue should be ready; in_progress is already claimed
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, "bd-open");
}
#[test]
fn test_next_child_number() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
// Create parent issue
let parent = make_issue("bd-parent", "Parent Epic", Status::Open, 2, None, t1, None);
storage.create_issue(&parent, "tester").unwrap();
// No children yet - should return 1
let next = storage.next_child_number("bd-parent").unwrap();
assert_eq!(next, 1, "First child should be .1");
// Create first child
let child1 = make_issue("bd-parent.1", "Child 1", Status::Open, 2, None, t1, None);
storage.create_issue(&child1, "tester").unwrap();
// Should now return 2
let next = storage.next_child_number("bd-parent").unwrap();
assert_eq!(next, 2, "After .1 exists, next should be .2");
// Create child with .3 (skip .2)
let child3 = make_issue("bd-parent.3", "Child 3", Status::Open, 2, None, t1, None);
storage.create_issue(&child3, "tester").unwrap();
// Should return 4 (max is 3, so next is 4)
let next = storage.next_child_number("bd-parent").unwrap();
assert_eq!(next, 4, "After .3 exists (skipping .2), next should be .4");
// Create grandchild - should not affect parent's next child number
let grandchild = make_issue(
"bd-parent.1.1",
"Grandchild",
Status::Open,
2,
None,
t1,
None,
);
storage.create_issue(&grandchild, "tester").unwrap();
// Parent's next child should still be 4
let next = storage.next_child_number("bd-parent").unwrap();
assert_eq!(
next, 4,
"Grandchild should not affect parent's next child number"
);
// Check grandchild's parent (bd-parent.1) next child number
let next_for_child1 = storage.next_child_number("bd-parent.1").unwrap();
assert_eq!(
next_for_child1, 2,
"After bd-parent.1.1 exists, next for bd-parent.1 should be .2"
);
}
#[test]
fn test_rebuild_child_counters_skips_missing_parents() {
let storage = SqliteStorage::open_memory().unwrap();
let timestamp = Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap();
let stamp = timestamp.to_rfc3339();
storage
.conn
.execute_with_params(
r"
INSERT INTO issues (
id, title, status, priority, issue_type, created_at, updated_at,
ephemeral, pinned, is_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
&[
SqliteValue::from("bd-orphan.6"),
SqliteValue::from("Recovered orphan child"),
SqliteValue::from("open"),
SqliteValue::from(2_i64),
SqliteValue::from("task"),
SqliteValue::from(stamp.as_str()),
SqliteValue::from(stamp.as_str()),
SqliteValue::from(0_i64),
SqliteValue::from(0_i64),
SqliteValue::from(0_i64),
],
)
.unwrap();
let rebuilt = storage.rebuild_child_counters_in_tx().unwrap();
assert_eq!(
rebuilt, 0,
"missing parents should not get counters rebuilt"
);
assert!(
!storage
.has_missing_issue_reference("child_counters", "parent_id")
.unwrap(),
"rebuild should not create orphan child counter rows"
);
}
#[test]
fn test_finish_issue_mutation_write_probe_returns_rollback_error_when_cleanup_fails() {
let result = finish_issue_mutation_write_probe(
Ok(1),
Err(FrankenError::Internal("rollback failed".to_string())),
);
let err = result.expect_err("rollback failure should surface");
assert!(
err.to_string().contains("rollback failed"),
"unexpected error: {err}"
);
}
#[test]
fn test_finish_issue_mutation_write_probe_composes_write_and_rollback_errors() {
let result = finish_issue_mutation_write_probe(
Err(FrankenError::Internal("write failed".to_string())),
Err(FrankenError::Internal("rollback failed".to_string())),
);
let err = result.expect_err("write and rollback failures should surface");
let message = err.to_string();
assert!(message.contains("write failed"), "{message}");
assert!(message.contains("rollback failed"), "{message}");
assert!(
message.contains("transaction state is unknown"),
"{message}"
);
}
#[test]
fn test_parse_datetime_empty_string_returns_epoch() {
let result = parse_datetime("").unwrap();
assert_eq!(result, DateTime::<Utc>::UNIX_EPOCH);
}
#[test]
fn test_parse_datetime_rfc3339_with_z() {
let result = parse_datetime("2026-02-26T19:54:42.715824474Z").unwrap();
assert_eq!(result.year(), 2026);
assert_eq!(result.month(), 2);
}
#[test]
fn test_parse_canonical_utc_datetime_matches_rfc3339_parser() {
let raw = "2026-01-16T07:21:09.280348123+00:00";
let fast = parse_canonical_utc_datetime(raw).expect("canonical UTC timestamp");
let general = DateTime::parse_from_rfc3339(raw)
.unwrap()
.with_timezone(&Utc);
assert_eq!(fast, general);
assert_eq!(fast.timestamp_subsec_nanos(), 280_348_123);
}
#[test]
fn test_parse_canonical_utc_datetime_leaves_non_utc_offsets_to_fallback() {
assert!(parse_canonical_utc_datetime("2026-01-16T07:21:09.280348123+01:00").is_none());
}
#[test]
fn test_parse_datetime_rfc3339_with_offset() {
let result = parse_datetime("2026-02-26T19:54:42+00:00").unwrap();
assert_eq!(result.year(), 2026);
}
#[test]
fn test_parse_datetime_naive_format() {
let result = parse_datetime("2026-02-26 19:54:42").unwrap();
assert_eq!(result.year(), 2026);
assert_eq!(result.month(), 2);
}
#[test]
fn test_parse_datetime_garbage_returns_error() {
let result = parse_datetime("not-a-date");
assert!(result.is_err());
}
#[test]
fn test_parse_datetime_value_text_roundtrips() {
let v = SqliteValue::from("2026-04-19T21:34:04.546468109Z");
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 4);
assert_eq!(dt.day(), 19);
}
#[test]
fn test_parse_datetime_value_integer_microseconds() {
// 1776651488000000 µs = 2026-04-20T02:18:08Z — the exact wire format
// the schema v6 migration repairs. The old reader (as_text().unwrap_or("")
// → parse_datetime) silently produced UNIX_EPOCH here.
let v = SqliteValue::Integer(1_776_651_488_000_000);
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 4);
assert_eq!(dt.day(), 20);
assert_eq!(dt.hour(), 2);
assert_eq!(dt.minute(), 18);
assert_eq!(dt.second(), 8);
}
#[test]
fn test_parse_datetime_value_integer_seconds() {
// 1_776_651_488 s = 2026-04-20T02:18:08Z
let v = SqliteValue::Integer(1_776_651_488);
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.hour(), 2);
}
#[test]
fn test_parse_datetime_value_integer_nanoseconds() {
let v = SqliteValue::Integer(1_776_651_488_000_000_000);
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.year(), 2026);
}
#[test]
fn test_datetime_from_epoch_auto_keeps_exact_unit_boundaries() {
let seconds = datetime_from_epoch_auto(10_000_000_000).unwrap();
assert_eq!(seconds.timestamp(), 10_000_000_000);
assert_eq!(seconds.timestamp_subsec_nanos(), 0);
let milliseconds = datetime_from_epoch_auto(10_000_000_000_000).unwrap();
assert_eq!(milliseconds.timestamp(), 10_000_000_000);
assert_eq!(milliseconds.timestamp_subsec_nanos(), 0);
let microseconds = datetime_from_epoch_auto(10_000_000_000_000_000).unwrap();
assert_eq!(microseconds.timestamp(), 10_000_000_000);
assert_eq!(microseconds.timestamp_subsec_nanos(), 0);
let negative_seconds = datetime_from_epoch_auto(-10_000_000_000).unwrap();
assert_eq!(negative_seconds.timestamp(), -10_000_000_000);
assert_eq!(negative_seconds.timestamp_subsec_nanos(), 0);
let negative_milliseconds = datetime_from_epoch_auto(-10_000_000_000_000).unwrap();
assert_eq!(negative_milliseconds.timestamp(), -10_000_000_000);
assert_eq!(negative_milliseconds.timestamp_subsec_nanos(), 0);
let negative_microseconds = datetime_from_epoch_auto(-10_000_000_000_000_000).unwrap();
assert_eq!(negative_microseconds.timestamp(), -10_000_000_000);
assert_eq!(negative_microseconds.timestamp_subsec_nanos(), 0);
}
#[test]
fn test_parse_datetime_value_null_is_epoch() {
assert_eq!(
parse_datetime_value(Some(&SqliteValue::Null)).unwrap(),
DateTime::<Utc>::UNIX_EPOCH
);
assert_eq!(
parse_datetime_value(None).unwrap(),
DateTime::<Utc>::UNIX_EPOCH
);
}
#[test]
fn test_parse_opt_datetime_value_integer_preserved_not_dropped() {
// The legacy get_opt_datetime path turned integer-typed columns
// into None; parse_opt_datetime_value must preserve the timestamp.
let v = SqliteValue::Integer(1_776_651_488_000_000);
let dt = parse_opt_datetime_value(Some(&v)).unwrap().unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.day(), 20);
}
#[test]
fn test_parse_opt_datetime_value_null_is_none() {
assert_eq!(
parse_opt_datetime_value(Some(&SqliteValue::Null)).unwrap(),
None
);
assert_eq!(parse_opt_datetime_value(None).unwrap(), None);
assert_eq!(
parse_opt_datetime_value(Some(&SqliteValue::from(""))).unwrap(),
None
);
}
#[test]
fn test_parse_datetime_value_rejects_blob() {
let v = SqliteValue::Blob(std::sync::Arc::from(b"bad".as_slice()));
assert!(parse_datetime_value(Some(&v)).is_err());
assert!(parse_opt_datetime_value(Some(&v)).is_err());
}
#[test]
fn test_datetime_from_epoch_seconds_f64_negative_fraction_floor_split() {
// Regression: the (secs, nanos) split must use floor(), not trunc(),
// so negative fractional seconds round the right way. With trunc()
// and abs(), `-1.5` incorrectly resolved to `-0.5` (secs=-1,
// nanos=5e8), one second higher than intended.
let dt = datetime_from_epoch_seconds_f64(-1.5).unwrap();
// -1.5 s before epoch = 1969-12-31T23:59:58.5Z
assert_eq!(dt.timestamp(), -2);
assert_eq!(dt.timestamp_subsec_nanos(), 500_000_000);
// Positive fractional remains correct after the fix.
let dt = datetime_from_epoch_seconds_f64(1_776_651_488.25).unwrap();
assert_eq!(dt.timestamp(), 1_776_651_488);
assert_eq!(dt.timestamp_subsec_nanos(), 250_000_000);
}
#[test]
fn test_datetime_from_epoch_seconds_f64_carries_rounded_nanoseconds() {
let dt = datetime_from_epoch_seconds_f64(1.999_999_999_6).unwrap();
assert_eq!(dt.timestamp(), 2);
assert_eq!(dt.timestamp_subsec_nanos(), 0);
let dt = datetime_from_epoch_seconds_f64(-0.000_000_000_4).unwrap();
assert_eq!(dt.timestamp(), 0);
assert_eq!(dt.timestamp_subsec_nanos(), 0);
}
#[test]
fn test_parse_datetime_value_integer_negative_is_pre_epoch() {
// -3600 s = 1969-12-31T23:00:00Z. Confirms negatives route through
// the seconds branch correctly (not mis-classified as a larger
// unit, and no wrap-around via unsigned_abs).
let v = SqliteValue::Integer(-3600);
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.timestamp(), -3600);
assert_eq!(dt.timestamp_subsec_nanos(), 0);
// Negative microsecond-range values pick up div_euclid/rem_euclid's
// floor semantics instead of truncating toward zero.
let v = SqliteValue::Integer(-1_500_000_000_000_000); // µs-range magnitude
let dt = parse_datetime_value(Some(&v)).unwrap();
assert_eq!(dt.timestamp(), -1_500_000_000);
assert_eq!(dt.timestamp_subsec_nanos(), 0);
}
#[test]
fn test_reset_data_tables_preserves_config() {
// Use a real file to avoid fsqlite in-memory BUSY contention under parallel tests.
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("test.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let t1 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
// Set config and create some issues
storage.set_config("issue_prefix", "test").unwrap();
storage.set_metadata("last_import", "2025-01-01").unwrap();
let issue = make_issue("test-1", "Issue 1", Status::Open, 2, None, t1, None);
storage.create_issue(&issue, "tester").unwrap();
// Verify issue exists
assert!(storage.get_issue("test-1").unwrap().is_some());
// Reset data tables
storage.reset_data_tables().unwrap();
assert!(
crate::storage::schema::runtime_schema_witness_matches(&storage.conn),
"reset must attest the recreated schema for subsequent fast opens"
);
assert_eq!(
storage.detect_recoverable_open_anomaly().unwrap(),
None,
"reset must not duplicate the persisted runtime schema witness"
);
// Config and metadata should be preserved
assert_eq!(
storage.get_config("issue_prefix").unwrap(),
Some("test".to_string()),
);
assert_eq!(
storage.get_metadata("last_import").unwrap(),
Some("2025-01-01".to_string()),
);
// Issue data should be gone
assert!(storage.get_issue("test-1").unwrap().is_none());
// Should be able to insert new issues (schema intact)
let issue2 = make_issue("test-2", "Issue 2", Status::Open, 2, None, t1, None);
storage.create_issue(&issue2, "tester").unwrap();
assert!(storage.get_issue("test-2").unwrap().is_some());
}
#[test]
fn test_open_seeds_known_metadata_defaults() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("seeded-metadata.db");
let storage = SqliteStorage::open(&db_path).unwrap();
let rows = storage
.conn
.query("SELECT key, value FROM metadata ORDER BY key ASC")
.unwrap();
let mut entries = HashMap::new();
for row in rows {
let key = row
.get(0)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
let value = row
.get(1)
.and_then(SqliteValue::as_text)
.unwrap_or("")
.to_string();
entries.insert(key, value);
}
for (key, default_value) in KNOWN_METADATA_DEFAULTS {
assert_eq!(
entries.get(key).map(String::as_str),
Some(default_value),
"expected seeded metadata default for key '{key}'"
);
}
assert_eq!(
storage.get_metadata(BLOCKED_CACHE_STATE_KEY).unwrap(),
None,
"empty blocked-cache seed should read as missing"
);
assert_eq!(
storage.get_metadata(METADATA_JSONL_CONTENT_HASH).unwrap(),
None,
"empty sync hash seed should read as missing"
);
assert_eq!(
storage.get_metadata(METADATA_LAST_EXPORT_TIME).unwrap(),
None,
"empty export timestamp seed should read as missing"
);
assert_eq!(
storage.get_metadata(METADATA_LAST_IMPORT_TIME).unwrap(),
None,
"empty import timestamp seed should read as missing"
);
}
#[test]
fn test_metadata_default_insert_rechecks_existing_key() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("metadata-default-race.db");
let storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute_with_params(
"DELETE FROM metadata WHERE key = ?",
&[SqliteValue::from(METADATA_JSONL_SIZE)],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_JSONL_SIZE),
SqliteValue::from("racing-writer"),
],
)
.unwrap();
SqliteStorage::insert_metadata_default_if_missing(
&storage.conn,
METADATA_JSONL_SIZE,
METADATA_EMPTY_VALUE,
)
.unwrap();
let rows = storage
.conn
.query_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid ASC",
&[SqliteValue::from(METADATA_JSONL_SIZE)],
)
.unwrap();
let values: Vec<String> = rows
.iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect();
assert_eq!(
values,
vec!["racing-writer".to_string()],
"default seeding must not duplicate or overwrite a key inserted by a racing opener"
);
}
#[test]
fn test_metadata_state_updates_keep_single_seeded_row() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("metadata-state.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_metadata(NEEDS_FLUSH_KEY, "true").unwrap();
storage.set_metadata(NEEDS_FLUSH_KEY, "false").unwrap();
storage.mark_blocked_cache_stale().unwrap();
storage.rebuild_blocked_cache(true).unwrap();
let needs_flush_count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM metadata WHERE key = ?",
&[SqliteValue::from(NEEDS_FLUSH_KEY)],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or_default();
assert_eq!(needs_flush_count, 1);
let blocked_cache_count = storage
.conn
.query_row_with_params(
"SELECT count(*) FROM metadata WHERE key = ?",
&[SqliteValue::from(BLOCKED_CACHE_STATE_KEY)],
)
.unwrap()
.get(0)
.and_then(SqliteValue::as_integer)
.unwrap_or_default();
assert_eq!(blocked_cache_count, 1);
assert_eq!(
storage.get_metadata(NEEDS_FLUSH_KEY).unwrap(),
Some("false".to_string())
);
assert_eq!(storage.get_metadata(BLOCKED_CACHE_STATE_KEY).unwrap(), None);
}
#[test]
fn test_metadata_duplicate_rows_read_latest_and_harmonize_on_write() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("metadata-duplicates.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_JSONL_CONTENT_HASH),
SqliteValue::from("stale-hash"),
],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_JSONL_CONTENT_HASH),
SqliteValue::from("latest-hash"),
],
)
.unwrap();
assert_eq!(
storage.get_metadata(METADATA_JSONL_CONTENT_HASH).unwrap(),
Some("latest-hash".to_string()),
"metadata reads must use the latest duplicate row"
);
storage
.set_metadata(METADATA_JSONL_CONTENT_HASH, "rewritten-hash")
.unwrap();
let rows = storage
.conn
.query_with_params(
"SELECT value FROM metadata WHERE key = ? ORDER BY rowid ASC",
&[SqliteValue::from(METADATA_JSONL_CONTENT_HASH)],
)
.unwrap();
let values: Vec<String> = rows
.iter()
.filter_map(|row| row.get(0).and_then(SqliteValue::as_text).map(String::from))
.collect();
assert_eq!(
values,
vec![
"rewritten-hash".to_string(),
"rewritten-hash".to_string(),
"rewritten-hash".to_string(),
],
"metadata writes must harmonize every duplicate row for the key"
);
}
#[test]
fn test_ready_readiness_probe_uses_latest_blocked_cache_state_duplicate() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("ready-stale-duplicate.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
storage
.create_issue(
&make_issue("bd-ready", "Ready issue", Status::Open, 1, None, now, None),
"tester",
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(BLOCKED_CACHE_STATE_KEY),
SqliteValue::from(BLOCKED_CACHE_STATE_STALE),
],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(BLOCKED_CACHE_STATE_KEY),
SqliteValue::from(METADATA_EMPTY_VALUE),
],
)
.unwrap();
let readiness = storage
.ready_readiness_probe(&ReadyFilters::default())
.unwrap();
assert!(readiness.has_candidate_status);
assert!(
!readiness.blocked_cache_stale,
"an older duplicate stale marker must not force the ready path to bypass the cache"
);
}
/// Regression: the Drop checkpoint heuristic from #270 fires only
/// when the handle accumulated mutations since the last periodic
/// `wal_checkpoint(PASSIVE)`. The read-only path keeps the
/// original "no checkpoint on teardown" behaviour described in
/// the Drop impl's comment so it doesn't re-introduce the
/// spurious busy failures the previous design avoided.
///
/// We assert the gate (`mutation_count > 0`) rather than the
/// post-Drop WAL file size because the actual on-disk effect of
/// `PRAGMA wal_checkpoint(TRUNCATE)` is fsqlite's responsibility
/// — its checkpoint executor decides whether the file is
/// truncated to zero or retained as a zero-frame header — and
/// keeping that detail out of beads_rust's regression suite
/// avoids a false alarm whenever fsqlite revises its WAL
/// teardown.
#[test]
fn test_drop_checkpoint_gate_tracks_mutation_count() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("drop-checkpoint.db");
// Mutating handle: create_issue must drive `mutation_count`
// above zero so the Drop impl's gate selects the checkpoint
// branch. This assertion is the single point of contract
// between the heuristic in `Drop` and the underlying
// `with_write_transaction` accounting.
{
let mut storage = SqliteStorage::open(&db_path).unwrap();
let now = Utc::now();
let issue = make_issue(
"bd-drop-1",
"drop-checkpoint",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&issue, "tester").unwrap();
assert!(
storage.mutation_count > 0,
"create_issue must increment mutation_count so the Drop heuristic checkpoints WAL"
);
// Drop runs at end of scope and exercises the checkpoint
// path. Any panic from inside Drop would surface as a
// test failure regardless of whether we observe the
// sidecar afterward.
}
// Re-opening must succeed and see the row, proving the
// committed data survived the Drop teardown intact (whether
// it lives in the main DB file post-checkpoint or in a
// replayed WAL is fsqlite's call).
let storage = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
storage.mutation_count, 0,
"fresh open must not pre-increment mutation_count"
);
assert!(
storage.get_issue("bd-drop-1").unwrap().is_some(),
"row written before drop must be visible after re-open"
);
}
/// Regression test for beads_rust-ok70: verify that status-change updates
/// complete successfully and the blocked cache is rebuilt without SQL parse
/// errors, even when the dependency graph is complex (parent-child chains,
/// cross-blocking, multiple epic parents).
#[test]
fn test_update_status_triggers_successful_cache_rebuild() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t = Utc::now();
let epic = make_issue("proj-epic", "Epic", Status::Open, 0, Some("epic"), t, None);
let task1 = make_issue(
"proj-task1",
"Task 1",
Status::Open,
1,
Some("task"),
t,
None,
);
let task2 = make_issue(
"proj-task2",
"Task 2",
Status::Open,
1,
Some("task"),
t,
None,
);
let task3 = make_issue(
"proj-task3",
"Task 3",
Status::Open,
2,
Some("task"),
t,
None,
);
let blocker = make_issue(
"proj-blocker",
"Blocker",
Status::Open,
1,
Some("bug"),
t,
None,
);
storage.create_issue(&epic, "tester").unwrap();
storage.create_issue(&task1, "tester").unwrap();
storage.create_issue(&task2, "tester").unwrap();
storage.create_issue(&task3, "tester").unwrap();
storage.create_issue(&blocker, "tester").unwrap();
// Build a complex dependency graph:
// epic <- task1 (parent-child)
// epic <- task2 (parent-child)
// task1 is blocked by blocker
// task3 is blocked by task2
storage
.add_dependency("proj-task1", "proj-epic", "parent-child", "tester")
.unwrap();
storage
.add_dependency("proj-task2", "proj-epic", "parent-child", "tester")
.unwrap();
storage
.add_dependency("proj-task1", "proj-blocker", "blocks", "tester")
.unwrap();
storage
.add_dependency("proj-task3", "proj-task2", "blocks", "tester")
.unwrap();
// Verify initial blocked state
let blocked = storage.get_blocked_issues().unwrap();
assert!(
blocked.iter().any(|(i, _)| i.id == "proj-task1"),
"task1 should be blocked by blocker"
);
assert!(
blocked.iter().any(|(i, _)| i.id == "proj-task3"),
"task3 should be blocked by task2"
);
// Update status to in_progress — this triggers blocked cache rebuild
let updates = IssueUpdate {
status: Some(Status::InProgress),
..Default::default()
};
let updated = storage
.update_issue("proj-blocker", &updates, "tester")
.unwrap();
assert_eq!(updated.status, Status::InProgress);
// Cache should still be consistent after rebuild
assert!(
!storage.blocked_cache_marked_stale().unwrap(),
"cache should not be stale after successful update"
);
// Close the blocker — should unblock task1
let close_updates = IssueUpdate {
status: Some(Status::Closed),
close_reason: Some(Some("done".to_string())),
..Default::default()
};
let closed = storage
.update_issue("proj-blocker", &close_updates, "tester")
.unwrap();
assert_eq!(closed.status, Status::Closed);
// task1 should now be unblocked
let blocked_after = storage.get_blocked_issues().unwrap();
assert!(
!blocked_after.iter().any(|(i, _)| i.id == "proj-task1"),
"task1 should be unblocked after blocker is closed"
);
// task3 should still be blocked by task2
assert!(
blocked_after.iter().any(|(i, _)| i.id == "proj-task3"),
"task3 should still be blocked by task2"
);
}
/// Regression test for beads_rust-m06q: closing a blocker and then
/// immediately updating the newly-unblocked dependent must not trigger a
/// UNIQUE constraint violation in blocked_issues_cache.
#[test]
fn test_close_blocker_then_claim_unblocked_issue_no_unique_violation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t = Utc::now();
let blocker = make_issue(
"m06q-blocker",
"Blocker",
Status::Open,
1,
Some("bug"),
t,
None,
);
let task = make_issue("m06q-task", "Task", Status::Open, 1, Some("task"), t, None);
storage.create_issue(&blocker, "tester").unwrap();
storage.create_issue(&task, "tester").unwrap();
storage
.add_dependency("m06q-task", "m06q-blocker", "blocks", "tester")
.unwrap();
// Verify task is blocked
let blocked = storage.get_blocked_issues().unwrap();
assert!(
blocked.iter().any(|(i, _)| i.id == "m06q-task"),
"task should be blocked initially"
);
// Close the blocker — triggers cache rebuild
let close_updates = IssueUpdate {
status: Some(Status::Closed),
close_reason: Some(Some("done".to_string())),
..Default::default()
};
storage
.update_issue("m06q-blocker", &close_updates, "tester")
.unwrap();
// Immediately "claim" the now-unblocked task by updating its status.
// This used to fail with UNIQUE constraint on blocked_issues_cache.issue_id
// when the cache rebuild inserted a duplicate row.
let claim_updates = IssueUpdate {
status: Some(Status::InProgress),
..Default::default()
};
let claimed = storage
.update_issue("m06q-task", &claim_updates, "tester")
.unwrap();
assert_eq!(claimed.status, Status::InProgress);
// Task should not appear in blocked list
let blocked_after = storage.get_blocked_issues().unwrap();
assert!(
!blocked_after.iter().any(|(i, _)| i.id == "m06q-task"),
"task should not be blocked after blocker was closed"
);
}
/// Extended regression for beads_rust-m06q: multiple blockers closed in
/// sequence with interleaved claims must not produce duplicate cache rows.
#[test]
fn test_multiple_close_claim_cycles_no_unique_violation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let t = Utc::now();
let b1 = make_issue(
"m06q-b1",
"Blocker 1",
Status::Open,
1,
Some("bug"),
t,
None,
);
let b2 = make_issue(
"m06q-b2",
"Blocker 2",
Status::Open,
1,
Some("bug"),
t,
None,
);
let task = make_issue(
"m06q-dual",
"Dual blocked",
Status::Open,
1,
Some("task"),
t,
None,
);
storage.create_issue(&b1, "tester").unwrap();
storage.create_issue(&b2, "tester").unwrap();
storage.create_issue(&task, "tester").unwrap();
storage
.add_dependency("m06q-dual", "m06q-b1", "blocks", "tester")
.unwrap();
storage
.add_dependency("m06q-dual", "m06q-b2", "blocks", "tester")
.unwrap();
// Task blocked by both
let blocked = storage.get_blocked_issues().unwrap();
assert!(blocked.iter().any(|(i, _)| i.id == "m06q-dual"));
// Close first blocker
let close = IssueUpdate {
status: Some(Status::Closed),
close_reason: Some(Some("done".to_string())),
..Default::default()
};
storage.update_issue("m06q-b1", &close, "tester").unwrap();
// Task still blocked by b2
let blocked = storage.get_blocked_issues().unwrap();
assert!(
blocked.iter().any(|(i, _)| i.id == "m06q-dual"),
"task should still be blocked by second blocker"
);
// Close second blocker
storage.update_issue("m06q-b2", &close, "tester").unwrap();
// Immediately claim — should succeed without UNIQUE violation
let claim = IssueUpdate {
status: Some(Status::InProgress),
..Default::default()
};
let claimed = storage.update_issue("m06q-dual", &claim, "tester").unwrap();
assert_eq!(claimed.status, Status::InProgress);
let blocked_after = storage.get_blocked_issues().unwrap();
assert!(
!blocked_after.iter().any(|(i, _)| i.id == "m06q-dual"),
"task should be unblocked after both blockers closed"
);
}
// ========================================================================
// get_open_dot_notation_children — supplementary guard for legacy/imported
// dot-notation parent-child relationships that lack formal dep rows.
// ========================================================================
#[test]
fn dot_notation_children_detects_open_direct_children() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let parent = make_issue("bd-epic", "Epic", Status::Open, 1, None, now, None);
let child1 = make_issue("bd-epic.1", "Child 1", Status::Open, 1, None, now, None);
let child2 = make_issue(
"bd-epic.2",
"Child 2",
Status::InProgress,
1,
None,
now,
None,
);
let mut child3 = make_issue("bd-epic.3", "Child 3", Status::Closed, 1, None, now, None);
// closed rows require closed_at set (DB CHECK constraint).
child3.closed_at = Some(now);
storage.create_issue(&parent, "t").unwrap();
storage.create_issue(&child1, "t").unwrap();
storage.create_issue(&child2, "t").unwrap();
storage.create_issue(&child3, "t").unwrap();
let mut open_children = storage.get_open_dot_notation_children("bd-epic").unwrap();
open_children.sort();
assert_eq!(
open_children,
vec!["bd-epic.1".to_string(), "bd-epic.2".to_string()],
"closed child should be excluded; open and in_progress should be returned"
);
}
#[test]
fn dot_notation_children_excludes_grandchildren() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let parent = make_issue("bd-root", "Root", Status::Open, 1, None, now, None);
let child = make_issue("bd-root.1", "Child", Status::Open, 1, None, now, None);
let grandchild = make_issue(
"bd-root.1.1",
"Grandchild",
Status::Open,
1,
None,
now,
None,
);
storage.create_issue(&parent, "t").unwrap();
storage.create_issue(&child, "t").unwrap();
storage.create_issue(&grandchild, "t").unwrap();
let open_children = storage.get_open_dot_notation_children("bd-root").unwrap();
assert_eq!(
open_children,
vec!["bd-root.1".to_string()],
"grandchildren (bd-root.1.1) must not be returned as direct children of bd-root"
);
}
#[test]
fn dot_notation_children_returns_empty_when_none() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let parent = make_issue("bd-solo", "Solo", Status::Open, 1, None, now, None);
let unrelated = make_issue("bd-other", "Other", Status::Open, 1, None, now, None);
storage.create_issue(&parent, "t").unwrap();
storage.create_issue(&unrelated, "t").unwrap();
let open_children = storage.get_open_dot_notation_children("bd-solo").unwrap();
assert!(
open_children.is_empty(),
"parent with no dot-notation children should return empty vec, got {open_children:?}"
);
}
#[test]
fn dot_notation_children_escapes_like_specials_in_parent_id() {
// If escape_like_pattern were bypassed, a parent id containing `_`
// would match any single character via LIKE. Pin the escaping behavior
// so `a_b-1.1` matches but `axb-1.1` does not.
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let parent = make_issue(
"a_b-1",
"Parent with underscore",
Status::Open,
1,
None,
now,
None,
);
let child = make_issue("a_b-1.1", "Real child", Status::Open, 1, None, now, None);
let decoy = make_issue("axb-1.1", "Decoy", Status::Open, 1, None, now, None);
storage.create_issue(&parent, "t").unwrap();
storage.create_issue(&child, "t").unwrap();
storage.create_issue(&decoy, "t").unwrap();
let open_children = storage.get_open_dot_notation_children("a_b-1").unwrap();
assert_eq!(
open_children,
vec!["a_b-1.1".to_string()],
"underscore in parent id must be escaped so `a_b-1.1` matches but `axb-1.1` does not"
);
}
#[test]
fn jittered_backoff_increases_with_attempt() {
let b0 = SqliteStorage::jittered_backoff(50, 0);
let b1 = SqliteStorage::jittered_backoff(50, 1);
let b2 = SqliteStorage::jittered_backoff(50, 2);
assert!((25..=75).contains(&b0), "attempt 0: {b0}");
assert!((50..=150).contains(&b1), "attempt 1: {b1}");
assert!((100..=300).contains(&b2), "attempt 2: {b2}");
}
#[test]
fn jittered_backoff_zero_base_returns_zero() {
let b = SqliteStorage::jittered_backoff(0, 0);
assert_eq!(b, 0, "zero base should not underflow");
}
#[test]
fn write_transaction_propagates_body_error() {
let dir = TempDir::new().unwrap();
let mut storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let result: Result<()> = storage
.with_write_transaction(|_| Err(crate::error::BeadsError::Config("test error".into())));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("test error"),
"should propagate body error, got: {err_msg}"
);
}
#[test]
fn write_transaction_surfaces_actual_rollback_failure_without_retrying_body() {
let dir = TempDir::new().unwrap();
let mut storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let mut body_attempts = 0_u8;
let result: Result<()> = storage.with_write_transaction(|storage| {
body_attempts += 1;
storage
.conn
.execute("ROLLBACK")
.expect("end transaction inside body");
Err(crate::error::BeadsError::Config(
"original transaction body failure".into(),
))
});
assert_eq!(body_attempts, 1, "unknown transaction state must not retry");
let err_msg = result.expect_err("second ROLLBACK must fail").to_string();
assert!(
err_msg.contains("ROLLBACK failed after transaction body error"),
"rollback failure context must be preserved: {err_msg}"
);
assert!(
err_msg.contains("transaction state is unknown and no retry was attempted"),
"operator guidance must forbid an unsafe retry: {err_msg}"
);
assert!(
err_msg.contains("original transaction body failure"),
"original body error must remain in the composed error: {err_msg}"
);
}
#[test]
fn read_transaction_composes_body_and_rollback_failures() {
let dir = TempDir::new().unwrap();
let storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let mut body_attempts = 0_u8;
let result: Result<()> = storage.with_read_transaction(|storage| {
body_attempts += 1;
storage
.conn
.execute("ROLLBACK")
.expect("end read transaction inside body");
Err(BeadsError::Config(
"original read transaction body failure".into(),
))
});
assert_eq!(body_attempts, 1);
let message = result
.expect_err("outer read rollback must fail")
.to_string();
assert!(
message.contains("original read transaction body failure"),
"{message}"
);
assert!(message.contains("ROLLBACK failed"), "{message}");
assert!(
message.contains("transaction state is unknown"),
"{message}"
);
}
#[test]
fn read_transaction_composes_commit_and_rollback_failures() {
let dir = TempDir::new().unwrap();
let storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let result: Result<()> = storage.with_read_transaction(|storage| {
storage
.conn
.execute("COMMIT")
.expect("end read transaction inside body");
Ok(())
});
let message = result
.expect_err("outer read COMMIT and cleanup ROLLBACK must fail")
.to_string();
assert!(
message.contains("ROLLBACK failed after read-transaction COMMIT error"),
"{message}"
);
assert!(
message.contains("transaction state is unknown"),
"{message}"
);
}
#[test]
fn open_auto_migrates_legacy_integer_datetimes_and_done_status() {
// Simulate the exact on-disk corruption observed in the wild: a v5
// DB (pre-migration user_version) with integer-typed DATETIME
// columns on some rows and a legacy Go-beads "done" status on
// another. Opening the DB with the fixed binary must auto-promote
// to v6 and normalize both.
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("legacy.db");
{
let storage = SqliteStorage::open(&db_path).unwrap();
// Drop the user_version so we exercise the v5 → v6 path.
storage.conn.execute("PRAGMA user_version = 5").unwrap();
storage
.conn
.execute(
"INSERT INTO issues (id, title, status, priority, issue_type, \
created_at, updated_at, closed_at, close_reason) VALUES \
('legacy-int', 'integer timestamps', 'closed', 2, 'task', \
'2026-04-19T21:34:04.000000000Z', 1776651488000000, 1776651488000000, 'done')",
)
.unwrap();
storage
.conn
.execute(
"INSERT INTO issues (id, title, status, priority, issue_type, \
created_at, updated_at) VALUES \
('legacy-done', 'bd done status', 'done', 2, 'task', \
'2026-04-02T20:00:00Z', '2026-04-03T01:00:00Z')",
)
.unwrap();
}
// Reopen — this triggers run_migrations(), which must repair both.
let storage = SqliteStorage::open(&db_path).unwrap();
let row = storage
.conn
.query_row(
"SELECT typeof(updated_at), typeof(closed_at), status FROM issues WHERE id='legacy-int'",
)
.unwrap();
assert_eq!(
row.get(0).and_then(SqliteValue::as_text),
Some("text"),
"updated_at should have been rewritten to TEXT"
);
assert_eq!(
row.get(1).and_then(SqliteValue::as_text),
Some("text"),
"closed_at should have been rewritten to TEXT"
);
assert_eq!(row.get(2).and_then(SqliteValue::as_text), Some("closed"));
let row = storage
.conn
.query_row("SELECT status, closed_at FROM issues WHERE id='legacy-done'")
.unwrap();
assert_eq!(row.get(0).and_then(SqliteValue::as_text), Some("closed"));
assert!(
row.get(1).and_then(SqliteValue::as_text).is_some(),
"closed_at should be populated for migrated done issue"
);
// The export path must now produce a correct Issue — the reader
// regression (silent 1970-01-01 / null) is gone.
let issue = storage
.get_issue_for_export("legacy-int")
.unwrap()
.expect("issue exists");
assert_eq!(issue.updated_at.year(), 2026);
assert_eq!(issue.updated_at.month(), 4);
assert_eq!(issue.updated_at.day(), 20);
assert!(issue.closed_at.is_some());
assert_eq!(issue.closed_at.unwrap().year(), 2026);
}
#[test]
fn connection_write_transaction_propagates_body_error() {
let dir = TempDir::new().unwrap();
let storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let result: Result<()> = storage.with_connection_write_transaction(|_| {
Err(crate::error::BeadsError::Config("conn test error".into()))
});
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("conn test error"),
"should propagate body error, got: {err_msg}"
);
}
#[test]
fn connection_write_transaction_surfaces_actual_rollback_failure_without_retrying_body() {
let dir = TempDir::new().unwrap();
let storage = SqliteStorage::open(&dir.path().join("test.db")).unwrap();
let mut body_attempts = 0_u8;
let result: Result<()> = storage.with_connection_write_transaction(|conn| {
body_attempts += 1;
conn.execute("ROLLBACK")
.expect("end shared transaction inside body");
Err(crate::error::BeadsError::Config(
"original shared transaction body failure".into(),
))
});
assert_eq!(body_attempts, 1, "unknown transaction state must not retry");
let err_msg = result
.expect_err("second shared ROLLBACK must fail")
.to_string();
assert!(
err_msg.contains("ROLLBACK failed after shared transaction body error"),
"rollback failure context must be preserved: {err_msg}"
);
assert!(
err_msg.contains("transaction state is unknown and no retry was attempted"),
"operator guidance must forbid an unsafe retry: {err_msg}"
);
assert!(
err_msg.contains("original shared transaction body failure"),
"original body error must remain in the composed error: {err_msg}"
);
}
#[cfg(unix)]
#[test]
fn attached_authority_rejects_replaced_database_before_both_write_transaction_paths() {
let dir = TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let displaced_path = beads_dir.join("beads.displaced.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
authority.verify_database_authority().unwrap();
storage.attach_write_authority(authority);
fs::rename(&db_path, &displaced_path).unwrap();
fs::copy(&displaced_path, &db_path).unwrap();
let exclusive_error = storage
.set_metadata("must_not_commit_exclusive", "value")
.expect_err("exclusive write must reject a replaced database inode")
.to_string();
assert!(
exclusive_error.contains("database")
&& (exclusive_error.contains("authority")
|| exclusive_error.contains("identity")
|| exclusive_error.contains("inode")),
"exclusive transaction should report the authority mismatch: {exclusive_error}"
);
let shared_error = storage
.set_metadata_shared("must_not_commit_shared", "value")
.expect_err("shared write must reject a replaced database inode")
.to_string();
assert!(
shared_error.contains("database")
&& (shared_error.contains("authority")
|| shared_error.contains("identity")
|| shared_error.contains("inode")),
"shared transaction should report the authority mismatch: {shared_error}"
);
// Since fsqlite 0.1.18 the engine itself fails closed (CannotOpen)
// on a connection whose underlying file was replaced, so post-scenario
// forensics must reopen the displaced inode fresh instead of reading
// through the stale connection.
drop(storage);
let displaced = SqliteStorage::open(&displaced_path).unwrap();
assert_eq!(
displaced.get_metadata("must_not_commit_exclusive").unwrap(),
None,
"the displaced inode must remain unchanged"
);
assert_eq!(
displaced.get_metadata("must_not_commit_shared").unwrap(),
None,
"the displaced inode must remain unchanged for the shared path"
);
drop(displaced);
let replacement = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
replacement
.get_metadata("must_not_commit_exclusive")
.unwrap(),
None,
"the unowned replacement inode must remain unchanged"
);
assert_eq!(
replacement.get_metadata("must_not_commit_shared").unwrap(),
None,
"the unowned replacement inode must remain unchanged"
);
}
#[cfg(unix)]
#[test]
fn write_transaction_reports_post_commit_authority_loss_without_retrying() {
let dir = TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
storage.attach_write_authority(authority);
let mut body_attempts = 0_u8;
REPLACE_ATTACHED_DATABASE_AFTER_COMMIT.with(|replace| replace.set(true));
let error = storage
.with_write_transaction(|storage| {
body_attempts += 1;
storage.set_metadata_in_tx("postcommit_exclusive", "committed")
})
.expect_err("post-COMMIT inode replacement must fail the witness check");
assert_eq!(
body_attempts, 1,
"a committed transaction must never be retried"
);
assert!(
matches!(&error, BeadsError::CommittedStateUnwitnessed { .. }),
"post-COMMIT authority loss must remain typed: {error:?}"
);
assert!(
!error.is_transient(),
"a potentially committed mutation must never be classified as retryable"
);
let structured = crate::error::StructuredError::from_error(&error);
assert!(!structured.retryable);
assert_eq!(structured.code.exit_code(), 6);
let context = structured.context.as_ref().expect("commit evidence");
assert_eq!(context["primary_commit_state"], "committed_unwitnessed");
assert_eq!(context["primary_committed"], true);
assert_eq!(context["primary_witnessed"], false);
assert_eq!(context["requires_reconciliation"], true);
assert_eq!(context["retryable"], false);
let error = error.to_string();
assert!(
error.contains("write transaction committed, but database authority changed"),
"{error}"
);
assert!(
error.contains("reconcile committed state before retrying"),
"{error}"
);
// The displaced connection fails closed. The committed WAL stays at
// the original path and replays over the hook's byte-identical copy.
drop(storage);
let reopened = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
reopened.get_metadata("postcommit_exclusive").unwrap(),
Some("committed".to_string()),
"the committed mutation must survive in the WAL at the database path"
);
}
#[cfg(unix)]
#[test]
fn shared_write_transaction_reports_post_commit_authority_loss_without_retrying() {
let dir = TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
let authority = Arc::new(
crate::sync::blocking_database_family_write_lock_with_timeout(
&beads_dir,
&db_path,
Some(1_000),
)
.unwrap(),
);
storage.attach_write_authority(authority);
let mut body_attempts = 0_u8;
REPLACE_ATTACHED_DATABASE_AFTER_COMMIT.with(|replace| replace.set(true));
let error = storage
.with_connection_write_transaction(|conn| {
body_attempts += 1;
SqliteStorage::upsert_metadata_key_in_tx(conn, "postcommit_shared", "committed")
})
.expect_err("post-COMMIT inode replacement must fail the shared witness check");
assert_eq!(
body_attempts, 1,
"a committed shared transaction must never be retried"
);
assert!(matches!(
&error,
BeadsError::CommittedStateUnwitnessed { .. }
));
assert!(!error.is_transient());
let error = error.to_string();
assert!(
error.contains("shared write transaction committed, but database authority changed"),
"{error}"
);
assert!(
error.contains("reconcile committed state before retrying"),
"{error}"
);
// fsqlite 0.1.18 fails closed on the displaced-inode connection, so
// verify the committed mutation with a fresh open of the database
// path: the commit lives in the WAL, which stays beside the original
// path and replays over the hook's byte-identical copy.
drop(storage);
let reopened = SqliteStorage::open(&db_path).unwrap();
assert_eq!(
reopened.get_metadata("postcommit_shared").unwrap(),
Some("committed".to_string()),
"the committed mutation must survive in the WAL at the database path"
);
}
// ========================================================================
// Issue #312, Layer 3 — attribution capture-only tests
// ========================================================================
#[test]
fn pending_attribution_is_stamped_onto_create_event() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 6, 7, 12, 0, 0).unwrap();
let issue = make_issue(
"bd-attr-1",
"Attributed create",
Status::Open,
2,
None,
now,
None,
);
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-7"),
Some("codex-cli"),
Some("opus-4"),
None,
));
storage.create_issue(&issue, "tester").expect("create");
let events = storage.get_events("bd-attr-1", 0).expect("events");
let created = events
.iter()
.find(|e| e.event_type == EventType::Created)
.expect("created event present");
assert_eq!(created.agent_name.as_deref(), Some("agent-7"));
assert_eq!(created.harness.as_deref(), Some("codex-cli"));
assert_eq!(created.model.as_deref(), Some("opus-4"));
}
#[test]
fn pending_attribution_is_stamped_onto_update_status_change_without_blocking() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 6, 7, 12, 0, 0).unwrap();
let issue = make_issue(
"bd-attr-2",
"Attributed update",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&issue, "tester").expect("create");
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-9"),
None,
Some("opus-4"),
None,
));
let update = IssueUpdate {
status: Some(Status::InProgress),
..Default::default()
};
// The transition must NOT be gated/rejected by the attribution; it is a
// recorded audit trail only.
let updated = storage
.update_issue("bd-attr-2", &update, "tester")
.expect("status change should not be blocked by attribution");
assert_eq!(updated.status, Status::InProgress);
let events = storage.get_events("bd-attr-2", 0).expect("events");
let status_event = events
.iter()
.find(|e| e.event_type == EventType::StatusChanged)
.expect("status_changed event present");
assert_eq!(status_event.agent_name.as_deref(), Some("agent-9"));
assert!(status_event.harness.is_none());
assert_eq!(status_event.model.as_deref(), Some("opus-4"));
}
#[test]
fn absent_attribution_records_no_values_and_does_not_leak() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 6, 7, 12, 0, 0).unwrap();
// First create stages attribution...
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-x"),
None,
None,
None,
));
let first = make_issue("bd-attr-3", "First", Status::Open, 2, None, now, None);
storage
.create_issue(&first, "tester")
.expect("create first");
// ...the second create stages NONE, so it must record no attribution
// (the prior staging must not leak into this unrelated mutation).
let second = make_issue("bd-attr-4", "Second", Status::Open, 2, None, now, None);
storage
.create_issue(&second, "tester")
.expect("create second");
let second_events = storage.get_events("bd-attr-4", 0).expect("events");
let created = second_events
.iter()
.find(|e| e.event_type == EventType::Created)
.expect("created event present");
assert!(created.agent_name.is_none());
assert!(created.harness.is_none());
assert!(created.model.is_none());
}
#[test]
fn event_attribution_normalizes_blank_inputs_to_none() {
let attribution = EventAttribution::new(Some(" "), Some(""), Some("opus-4"), None);
assert!(attribution.agent_name.is_none());
assert!(attribution.harness.is_none());
assert_eq!(attribution.model.as_deref(), Some("opus-4"));
assert!(!attribution.is_empty());
assert!(EventAttribution::new(None, None, None, None).is_empty());
}
// ---- #312 hardening (F1): attribution survives a non-committing mutation
// and is consumed only after a successful commit -----------------------
#[test]
fn pending_attribution_survives_failed_mutation_and_stamps_on_retry() {
// Models the JSONL-recovery retry path: the first `mutate()` does NOT
// commit (its closure errors → rollback), so the staged attribution must
// remain available for the *next* `mutate()` to stamp. Previously the
// start-of-`mutate()` `.take()` consumed it on the failed attempt,
// dropping attribution from the recovered write (F1).
let mut storage = SqliteStorage::open_memory().unwrap();
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-retry"),
None,
Some("opus-4"),
None,
));
// A mutation whose closure fails with a non-transient error: it rolls
// back and never commits. The staged slot must be left intact.
let failed: Result<()> = storage.mutate("noop_fail", "tester", |_conn, _ctx| {
Err(BeadsError::Config("simulated mutation failure".into()))
});
assert!(failed.is_err(), "mutation closure error should propagate");
assert!(
storage.pending_event_attribution.is_some(),
"attribution must survive a non-committing mutation so the recovery \
retry can still stamp it",
);
// The retry: a committing mutation must now record the (still-staged)
// attribution onto its events.
let now = Utc.with_ymd_and_hms(2026, 6, 7, 12, 0, 0).unwrap();
let issue = make_issue("bd-attr-retry", "Retried", Status::Open, 2, None, now, None);
storage.create_issue(&issue, "tester").expect("create");
let events = storage.get_events("bd-attr-retry", 0).expect("events");
let created = events
.iter()
.find(|e| e.event_type == EventType::Created)
.expect("created event present");
assert_eq!(created.agent_name.as_deref(), Some("agent-retry"));
assert_eq!(created.model.as_deref(), Some("opus-4"));
// And after the committing mutation, the slot is cleared (consumed once).
assert!(
storage.pending_event_attribution.is_none(),
"attribution must be consumed by exactly one committing mutation",
);
}
// ---- #312 hardening (F2): no-op update clears the staged slot ----------
#[test]
fn empty_update_clears_pending_attribution_so_it_does_not_leak() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 6, 7, 12, 0, 0).unwrap();
let issue = make_issue(
"bd-attr-noop",
"No-op target",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&issue, "tester").expect("create");
// Stage attribution, then call update_issue with EMPTY updates: this
// early-returns without calling `mutate()`, but must still drain the
// staged slot so it cannot leak onto the next, unrelated mutation (F2).
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-leak"),
None,
None,
None,
));
let empty = IssueUpdate::default();
storage
.update_issue("bd-attr-noop", &empty, "tester")
.expect("empty update is a no-op read");
assert!(
storage.pending_event_attribution.is_none(),
"empty-update no-op must clear the staged attribution",
);
// Confirm no leak: a subsequent create (which stages nothing) records
// no attribution.
let next = make_issue("bd-attr-next", "Next", Status::Open, 2, None, now, None);
storage.create_issue(&next, "tester").expect("create next");
let events = storage.get_events("bd-attr-next", 0).expect("events");
let created = events
.iter()
.find(|e| e.event_type == EventType::Created)
.expect("created event present");
assert!(created.agent_name.is_none());
assert!(created.harness.is_none());
assert!(created.model.is_none());
}
#[test]
#[allow(clippy::too_many_lines)]
fn sync_merge_transaction_commits_rows_relations_notes_and_operational_state_together() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let victim = make_issue(
"bd-merge-victim",
"Delete me",
Status::Open,
2,
None,
now,
None,
);
let mut parent = make_issue(
"bd-merge-parent",
"Old parent",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&victim, "fixture").unwrap();
storage.create_issue(&parent, "fixture").unwrap();
storage
.set_export_hash(&victim.id, "victim-export-hash")
.unwrap();
storage
.set_export_hash(&parent.id, "parent-export-hash")
.unwrap();
storage.clear_all_dirty_issues().unwrap();
parent.title = "Merged parent".to_string();
parent.labels = vec!["sync".to_string(), "verified".to_string()];
parent.comments = vec![crate::model::Comment {
id: 7001,
issue_id: parent.id.clone(),
author: "fixture".to_string(),
body: "Imported merge comment".to_string(),
created_at: now,
}];
let mut child = make_issue(
"bd-merge-parent.1",
"Merged child",
Status::Open,
2,
None,
now,
None,
);
child.dependencies = vec![crate::model::Dependency {
issue_id: child.id.clone(),
depends_on_id: parent.id.clone(),
dep_type: crate::model::DependencyType::ParentChild,
created_at: now,
created_by: Some("fixture".to_string()),
metadata: Some("{}".to_string()),
thread_id: None,
}];
let kept = vec![parent.clone(), child.clone()];
let deleted = vec![victim.id.clone()];
let notes = vec![(
parent.id.clone(),
"Merge resolution selected the reviewed generation.".to_string(),
)];
let intent = sync_merge_test_intent(&storage, &kept, &deleted, ¬es);
let pending_receipt = storage
.apply_sync_merge_atomically(&kept, &deleted, ¬es, &intent)
.unwrap();
let stored_parent = storage.get_issue(&parent.id).unwrap().unwrap();
assert_eq!(stored_parent.title, "Merged parent");
assert_eq!(
storage.get_labels(&parent.id).unwrap(),
vec!["sync", "verified"]
);
assert_eq!(
storage
.get_dependencies_full(&child.id)
.unwrap()
.first()
.map(|dependency| dependency.depends_on_id.as_str()),
Some(parent.id.as_str())
);
assert_eq!(storage.next_child_number(&parent.id).unwrap(), 2);
let parent_comments = storage.get_comments(&parent.id).unwrap();
assert_eq!(parent_comments.len(), 2);
assert!(
parent_comments
.iter()
.any(|comment| comment.body == "Imported merge comment")
);
let merge_note = parent_comments
.iter()
.find(|comment| comment.body == "Merge resolution selected the reviewed generation.")
.expect("merge note comment");
assert_eq!(merge_note.author, "br-sync");
assert_eq!(merge_note.created_at, intent.export_as_of);
let tombstone = storage.get_issue(&victim.id).unwrap().unwrap();
assert_eq!(tombstone.status, Status::Tombstone);
assert_eq!(tombstone.deleted_by.as_deref(), Some("merge-agent"));
assert_eq!(tombstone.delete_reason.as_deref(), Some("merge deletion"));
assert!(
storage
.get_events(&victim.id, 0)
.unwrap()
.iter()
.any(|event| event.event_type == EventType::Deleted)
);
let merge_note_event = storage
.get_events(&parent.id, 0)
.unwrap()
.into_iter()
.find(|event| {
event.event_type == EventType::Commented
&& event.comment.as_deref()
== Some("Merge resolution selected the reviewed generation.")
})
.expect("merge note audit event");
assert_eq!(merge_note_event.actor, "merge-agent");
assert_eq!(merge_note_event.created_at, intent.export_as_of);
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(pending_receipt)
);
assert_eq!(
storage.get_metadata("needs_flush").unwrap().as_deref(),
Some("true")
);
assert_eq!(
storage.get_dirty_issue_ids().unwrap(),
vec![parent.id, child.id, victim.id]
);
assert!(
storage
.get_export_hash("bd-merge-parent")
.unwrap()
.is_none()
);
assert!(
storage
.get_export_hash("bd-merge-victim")
.unwrap()
.is_none()
);
}
#[test]
fn sync_merge_transaction_rolls_back_rows_when_relation_validation_fails() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let mut invalid = make_issue(
"bd-merge-invalid-relation",
"Must roll back",
Status::Open,
2,
None,
now,
None,
);
invalid.dependencies = vec![crate::model::Dependency {
issue_id: invalid.id.clone(),
depends_on_id: "bd-dependency-target".to_string(),
dep_type: crate::model::DependencyType::Blocks,
created_at: now,
created_by: None,
metadata: Some("not-json".to_string()),
thread_id: None,
}];
let kept = vec![invalid.clone()];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let result = storage.apply_sync_merge_atomically(&kept, &[], &[], &intent);
assert!(matches!(result, Err(BeadsError::Validation { .. })));
assert!(storage.get_issue(&invalid.id).unwrap().is_none());
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
assert_eq!(storage.get_dirty_issue_count().unwrap(), 0);
}
#[test]
fn sync_merge_transaction_rolls_back_rows_when_note_target_is_absent() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let kept = make_issue(
"bd-merge-before-note-failure",
"Must roll back",
Status::Open,
2,
None,
now,
None,
);
let notes = vec![(
"bd-absent-note-target".to_string(),
"Valid note with an absent owner.".to_string(),
)];
let intent = sync_merge_test_intent(&storage, std::slice::from_ref(&kept), &[], ¬es);
let result =
storage.apply_sync_merge_atomically(std::slice::from_ref(&kept), &[], ¬es, &intent);
assert!(result.is_err());
assert!(storage.get_issue(&kept.id).unwrap().is_none());
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
assert_eq!(storage.get_dirty_issue_count().unwrap(), 0);
}
#[test]
fn sync_merge_transaction_rolls_back_rows_when_cache_rebuild_fails() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 7, 0, 0).unwrap();
let parent = make_issue(
"bd-merge-cache-parent",
"Must roll back with child",
Status::Open,
2,
None,
now,
None,
);
let child = make_issue(
"bd-merge-cache-parent.1",
"Child forces a counter insert",
Status::Open,
2,
None,
now,
None,
);
storage
.conn
.execute(
"CREATE TRIGGER fail_sync_merge_child_counter
BEFORE INSERT ON child_counters
BEGIN
SELECT RAISE(ABORT, 'injected child-counter rebuild failure');
END",
)
.unwrap();
let kept = vec![parent.clone(), child.clone()];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let result = storage.apply_sync_merge_atomically(&kept, &[], &[], &intent);
assert!(result.is_err());
assert!(storage.get_issue(&parent.id).unwrap().is_none());
assert!(storage.get_issue(&child.id).unwrap().is_none());
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
assert_eq!(storage.get_dirty_issue_count().unwrap(), 0);
}
#[test]
#[allow(clippy::too_many_lines)]
fn sync_merge_pending_receipt_roundtrips_and_rejects_full_envelope_tampering() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 8, 0, 0).unwrap();
let issue = make_issue(
"bd-merge-receipt",
"Receipt target",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let receipt = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
let expected_intent_sha256 = receipt.intent.intent_sha256().unwrap();
assert_eq!(receipt.intent_sha256, expected_intent_sha256);
assert_ne!(
receipt.receipt_id, expected_intent_sha256,
"the receipt ID must identify the full immutable evidence envelope"
);
assert_ne!(
receipt.state_sha256, receipt.receipt_id,
"state and immutable-envelope digests must use distinct domains"
);
receipt.validate().unwrap();
let serialized = serde_json::to_string(&receipt).unwrap();
let roundtrip = serde_json::from_str::<SyncMergePendingReceipt>(&serialized).unwrap();
assert_eq!(roundtrip, receipt);
assert_eq!(roundtrip.intent.export_as_of, intent.export_as_of);
roundtrip.validate().unwrap();
let mut different_cutoff = receipt.intent.clone();
different_cutoff.export_as_of += chrono::Duration::nanoseconds(1);
assert_ne!(
different_cutoff.intent_sha256().unwrap(),
receipt.intent_sha256,
"the frozen export cutoff must be hash-bound into merge intent"
);
let reviewed_issue_hashes = sync_merge_test_export_hashes(&storage, &receipt.intent);
assert!(
SyncMergePendingReceipt::new(
receipt.intent.clone(),
(now + chrono::Duration::nanoseconds(1)).to_rfc3339(),
receipt.database_after.clone(),
receipt.jsonl_after_raw_sha256.clone(),
receipt.jsonl_after_issue_count,
&reviewed_issue_hashes,
Vec::new(),
)
.is_err(),
"receipt creation time and frozen export cutoff must identify one instant"
);
let mut unsupported_schema = receipt.clone();
unsupported_schema.schema_version += 1;
let mut tampered_intent_schema = receipt.clone();
tampered_intent_schema.intent.schema_version += 1;
let mut tampered_intent = receipt.clone();
tampered_intent.intent.resolution = "force-db".to_string();
tampered_intent.intent_sha256 = tampered_intent.intent.intent_sha256().unwrap();
let mut tampered_intent_digest = receipt.clone();
tampered_intent_digest.intent_sha256 = "10".repeat(32);
let mut tampered_created_at = receipt.clone();
tampered_created_at.created_at = "2026-07-27T08:00:00.000000001+00:00".to_string();
let mut tampered_database_after = receipt.clone();
tampered_database_after.database_after.issue_payload_sha256 = "20".repeat(32);
let mut tampered_raw_hash = receipt.clone();
tampered_raw_hash.jsonl_after_raw_sha256 = "30".repeat(32);
let mut tampered_content_hash = receipt.clone();
tampered_content_hash.jsonl_after_content_sha256 = "40".repeat(32);
let mut tampered_count = receipt.clone();
tampered_count.jsonl_after_issue_count += 1;
let mut tampered_identity = receipt.clone();
tampered_identity.receipt_id = "50".repeat(32);
let mut tampered_state_digest = receipt.clone();
tampered_state_digest.state_sha256 = "60".repeat(32);
for (field, tampered) in vec![
("schema_version", unsupported_schema),
("intent.schema_version", tampered_intent_schema),
("intent", tampered_intent),
("intent_sha256", tampered_intent_digest),
("created_at", tampered_created_at),
("database_after", tampered_database_after),
("jsonl_after_raw_sha256", tampered_raw_hash),
("jsonl_after_content_sha256", tampered_content_hash),
("jsonl_after_issue_count", tampered_count),
("receipt_id", tampered_identity),
("state_sha256", tampered_state_digest),
]
.into_boxed_slice()
{
storage
.set_metadata(
METADATA_SYNC_MERGE_PENDING,
&serde_json::to_string(&tampered).unwrap(),
)
.unwrap();
assert!(
matches!(
storage.pending_sync_merge_receipt(),
Err(BeadsError::SyncConflict { .. })
),
"persisted {field} tampering must be rejected"
);
}
storage
.set_metadata(
METADATA_SYNC_MERGE_PENDING,
&serde_json::to_string(&receipt).unwrap(),
)
.unwrap();
assert_eq!(storage.pending_sync_merge_receipt().unwrap(), Some(receipt));
}
#[test]
fn sync_merge_pending_receipt_state_digest_rejects_phase_and_witness_tampering() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 8, 5, 0).unwrap();
let issue = make_issue(
"bd-merge-state",
"State digest target",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let committed = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
let finalized = finalized_sync_merge_test_receipt(&mut storage, &committed);
assert_eq!(finalized.receipt_id, committed.receipt_id);
assert_ne!(finalized.state_sha256, committed.state_sha256);
finalized.validate().unwrap();
let mut stale_committed_state = finalized.clone();
stale_committed_state.phase = crate::sync::SyncMergePendingPhase::DatabaseCommitted;
stale_committed_state.jsonl_after = None;
let mut stale_finalized_state = finalized.clone();
let Some(crate::sync::JsonlSourceStateWitness::Present { size, .. }) =
stale_finalized_state.jsonl_after.as_mut()
else {
panic!("finalized fixture must contain a present JSONL witness");
};
*size += 1;
let mut stale_finalization_witness = finalized.clone();
stale_finalization_witness
.export_finalization
.as_mut()
.expect("finalized fixture must contain database bookkeeping")
.export_hashes
.payload_sha256 = "ab".repeat(32);
for (field, tampered) in [
("phase", stale_committed_state),
("jsonl_after", stale_finalized_state),
("export_finalization", stale_finalization_witness),
] {
storage
.set_metadata(
METADATA_SYNC_MERGE_PENDING,
&serde_json::to_string(&tampered).unwrap(),
)
.unwrap();
assert!(
matches!(
storage.pending_sync_merge_receipt(),
Err(BeadsError::SyncConflict { .. })
),
"persisted {field} tampering without a state digest update must be rejected"
);
}
let finalization =
crate::sync::capture_sync_merge_export_finalization_witness(&storage).unwrap();
assert!(matches!(
finalized.advance_to_export_finalized(
crate::sync::JsonlSourceStateWitness::Present {
raw_sha256: finalized.jsonl_after_raw_sha256.clone(),
mtime: "2026-07-27T08:00:00+00:00".to_string(),
size: 129,
identity: None,
},
finalization.clone(),
),
Err(BeadsError::SyncConflict { .. })
));
assert!(matches!(
committed.advance_to_export_finalized(
crate::sync::JsonlSourceStateWitness::Missing,
finalization.clone(),
),
Err(BeadsError::SyncConflict { .. })
));
assert!(matches!(
committed.advance_to_export_finalized(
crate::sync::JsonlSourceStateWitness::Present {
raw_sha256: "70".repeat(32),
mtime: "2026-07-27T08:00:00+00:00".to_string(),
size: 128,
identity: None,
},
finalization,
),
Err(BeadsError::SyncConflict { .. })
));
}
#[test]
fn sync_merge_existing_pending_receipt_blocks_second_merge_without_mutation() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 8, 15, 0).unwrap();
let first = make_issue(
"bd-merge-first",
"First committed merge",
Status::Open,
2,
None,
now,
None,
);
let first_kept = vec![first];
let first_intent = sync_merge_test_intent(&storage, &first_kept, &[], &[]);
let first_receipt = storage
.apply_sync_merge_atomically(&first_kept, &[], &[], &first_intent)
.unwrap();
let second = make_issue(
"bd-merge-second",
"Must remain absent",
Status::Open,
2,
None,
now,
None,
);
let second_kept = vec![second.clone()];
let second_intent = sync_merge_test_intent(&storage, &second_kept, &[], &[]);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
let error = storage
.apply_sync_merge_atomically(&second_kept, &[], &[], &second_intent)
.unwrap_err();
assert!(matches!(error, BeadsError::SyncConflict { .. }));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before
);
assert!(storage.get_issue(&second.id).unwrap().is_none());
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(first_receipt)
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn sync_merge_pending_receipt_cas_rejects_stale_backward_and_immutable_changes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 8, 30, 0).unwrap();
let issue = make_issue(
"bd-merge-cas",
"CAS target",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let committed = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &committed),
Err(BeadsError::SyncConflict { .. })
));
let finalized = finalized_sync_merge_test_receipt(&mut storage, &committed);
let mut stale = committed.clone();
stale.created_at = "2026-07-27T08:31:00+00:00".to_string();
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&stale, &finalized),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(committed.clone())
);
assert_eq!(finalized.receipt_id, committed.receipt_id);
assert_eq!(finalized.intent_sha256, committed.intent_sha256);
assert_eq!(finalized.created_at, committed.created_at);
assert_eq!(finalized.database_after, committed.database_after);
assert_eq!(
finalized.jsonl_after_raw_sha256,
committed.jsonl_after_raw_sha256
);
assert_eq!(
finalized.jsonl_after_content_sha256,
committed.jsonl_after_content_sha256
);
assert_eq!(
finalized.jsonl_after_issue_count,
committed.jsonl_after_issue_count
);
assert_ne!(finalized.state_sha256, committed.state_sha256);
let mut cross_intent = committed.intent.clone();
cross_intent.resolution = "force-db".to_string();
let cross_issue_hashes = sync_merge_test_export_hashes(&storage, &cross_intent);
let cross_committed = SyncMergePendingReceipt::new(
cross_intent,
committed.created_at.clone(),
committed.database_after.clone(),
committed.jsonl_after_raw_sha256.clone(),
committed.jsonl_after_issue_count,
&cross_issue_hashes,
Vec::new(),
)
.unwrap();
let changed_identity = finalized_sync_merge_test_receipt(&mut storage, &cross_committed);
assert_ne!(changed_identity.receipt_id, committed.receipt_id);
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &changed_identity),
Err(BeadsError::SyncConflict { .. })
));
let mut changed_created_at = finalized.clone();
changed_created_at.created_at = "2026-07-27T08:32:00+00:00".to_string();
let mut changed_core = finalized.clone();
changed_core.database_after.issue_payload_sha256 = "aa".repeat(32);
let mut changed_hash = finalized.clone();
changed_hash.jsonl_after_content_sha256 = "bb".repeat(32);
let mut changed_count = finalized.clone();
changed_count.jsonl_after_issue_count += 1;
for changed in [
changed_created_at,
changed_core,
changed_hash,
changed_count,
] {
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &changed),
Err(BeadsError::SyncConflict { .. })
));
}
storage
.set_metadata("sync_merge_cas_core_drift", "must-block")
.unwrap();
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &finalized),
Err(BeadsError::SyncConflict { .. })
));
assert!(
storage
.delete_metadata("sync_merge_cas_core_drift")
.unwrap()
);
let committed_serialized = serde_json::to_string(&committed).unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_SYNC_MERGE_PENDING),
SqliteValue::from(committed_serialized.as_str()),
],
)
.unwrap();
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &finalized),
Err(BeadsError::SyncConflict { .. })
));
storage
.conn
.execute_with_params(
"DELETE FROM metadata \
WHERE rowid = (SELECT MAX(rowid) FROM metadata WHERE key = ?)",
&[SqliteValue::from(METADATA_SYNC_MERGE_PENDING)],
)
.unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_SYNC_MERGE_PENDING_LEGACY),
SqliteValue::from("legacy-pending-state"),
],
)
.unwrap();
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&committed, &finalized),
Err(BeadsError::SyncConflict { .. })
));
storage
.conn
.execute_with_params(
"DELETE FROM metadata WHERE key = ?",
&[SqliteValue::from(METADATA_SYNC_MERGE_PENDING_LEGACY)],
)
.unwrap();
storage
.compare_and_set_pending_sync_merge_receipt(&committed, &finalized)
.unwrap();
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(finalized.clone())
);
assert!(matches!(
storage.compare_and_set_pending_sync_merge_receipt(&finalized, &committed),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(finalized)
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn sync_merge_pending_receipt_clear_requires_exact_terminal_value() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 8, 45, 0).unwrap();
let issue = make_issue(
"bd-merge-clear",
"Clear target",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let committed = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&committed),
Err(BeadsError::SyncConflict { .. })
));
let mut incomplete_terminal = committed.clone();
incomplete_terminal.phase = crate::sync::SyncMergePendingPhase::ExportFinalized;
incomplete_terminal.jsonl_after = Some(crate::sync::JsonlSourceStateWitness::Missing);
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&incomplete_terminal),
Err(BeadsError::SyncConflict { .. })
));
let finalized = finalized_sync_merge_test_receipt(&mut storage, &committed);
storage
.compare_and_set_pending_sync_merge_receipt(&committed, &finalized)
.unwrap();
let finalized_serialized = serde_json::to_string(&finalized).unwrap();
storage
.conn
.execute_with_params(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
&[
SqliteValue::from(METADATA_SYNC_MERGE_PENDING),
SqliteValue::from(finalized_serialized.as_str()),
],
)
.unwrap();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
));
storage
.conn
.execute_with_params(
"DELETE FROM metadata \
WHERE rowid = (SELECT MAX(rowid) FROM metadata WHERE key = ?)",
&[SqliteValue::from(METADATA_SYNC_MERGE_PENDING)],
)
.unwrap();
storage
.set_metadata("sync_merge_clear_core_drift", "must-block")
.unwrap();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
));
assert!(
storage
.delete_metadata("sync_merge_clear_core_drift")
.unwrap()
);
let mut stale_finalized = finalized.clone();
stale_finalized.created_at = "2026-07-27T08:46:00+00:00".to_string();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&stale_finalized),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(finalized.clone())
);
let reviewed_export_hash = sync_merge_test_export_hashes(&storage, &finalized.intent)
.into_iter()
.find_map(|(issue_id, content_hash)| (issue_id == kept[0].id).then_some(content_hash))
.expect("reviewed export mapping contains the kept issue");
let original_export_hash = storage
.get_export_hash(&kept[0].id)
.unwrap()
.expect("finalized export-hash fixture");
storage
.set_export_hash(&kept[0].id, "drifted-export-hash")
.unwrap();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(original_export_hash.0, reviewed_export_hash);
storage
.with_write_transaction(|storage| {
storage.conn.execute_with_params(
"DELETE FROM export_hashes WHERE issue_id = ?",
&[SqliteValue::from(kept[0].id.as_str())],
)?;
storage.conn.execute_with_params(
"INSERT INTO export_hashes (issue_id, content_hash, exported_at) \
VALUES (?, ?, ?)",
&[
SqliteValue::from(kept[0].id.as_str()),
SqliteValue::from(original_export_hash.0.as_str()),
SqliteValue::from(original_export_hash.1.as_str()),
],
)?;
Ok(())
})
.unwrap();
storage
.replace_dirty_issue_marker(&kept[0].id, "2026-07-27T08:45:01+00:00")
.unwrap();
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
));
storage
.clear_dirty_flags(std::slice::from_ref(&kept[0].id))
.unwrap();
for (key, drifted) in [
(METADATA_JSONL_CONTENT_HASH, "drifted-content-hash"),
(METADATA_JSONL_MTIME, "2026-07-27T08:46:00+00:00"),
(METADATA_JSONL_SIZE, "129"),
(METADATA_LAST_EXPORT_TIME, "2026-07-27T08:47:00+00:00"),
("needs_flush", "true"),
] {
let original = storage
.get_metadata(key)
.unwrap()
.expect("finalized metadata fixture");
storage.set_metadata(key, drifted).unwrap();
assert!(
matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
),
"drift in finalized metadata key {key} must prevent receipt cleanup"
);
storage.set_metadata(key, &original).unwrap();
}
assert_eq!(
storage.pending_sync_merge_receipt().unwrap(),
Some(finalized.clone()),
"all rejected finalization drift must preserve the exact receipt"
);
storage
.compare_and_clear_pending_sync_merge_receipt(&finalized)
.unwrap();
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
assert!(matches!(
storage.compare_and_clear_pending_sync_merge_receipt(&finalized),
Err(BeadsError::SyncConflict { .. })
));
}
#[test]
fn sync_merge_core_witness_ignores_only_export_finalization_bookkeeping() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 0, 0).unwrap();
let issue = make_issue(
"bd-merge-core",
"Core witness target",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue.clone()];
for (key, value) in [
("unrelated_sync_merge_metadata", "baseline"),
("project", "baseline-project"),
(METADATA_LAST_IMPORT_TIME, "2026-07-27T08:59:00+00:00"),
] {
storage.set_metadata(key, value).unwrap();
}
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let committed = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
assert_eq!(
crate::sync::capture_sync_merge_core_witness(&storage).unwrap(),
committed.database_after
);
storage
.set_export_hash(&issue.id, "reviewed-export-hash")
.unwrap();
let dirty_ids = storage.get_dirty_issue_ids().unwrap();
storage.clear_dirty_flags(&dirty_ids).unwrap();
storage
.set_metadata(METADATA_JSONL_CONTENT_HASH, "canonical-jsonl-hash")
.unwrap();
storage
.set_metadata(METADATA_JSONL_MTIME, "2026-07-27T09:01:00+00:00")
.unwrap();
storage.set_metadata(METADATA_JSONL_SIZE, "128").unwrap();
storage.set_metadata("needs_flush", "false").unwrap();
storage
.set_metadata(METADATA_LAST_EXPORT_TIME, "2026-07-27T09:01:00+00:00")
.unwrap();
let finalized = finalized_sync_merge_test_receipt(&mut storage, &committed);
storage
.compare_and_set_pending_sync_merge_receipt(&committed, &finalized)
.unwrap();
assert_eq!(
crate::sync::capture_sync_merge_core_witness(&storage).unwrap(),
committed.database_after
);
for (key, replacement) in [
("unrelated_sync_merge_metadata", "must-be-bound"),
("project", "different-project"),
(METADATA_LAST_IMPORT_TIME, "2026-07-27T09:02:00+00:00"),
] {
let original = storage.get_metadata(key).unwrap();
storage.set_metadata(key, replacement).unwrap();
assert_ne!(
crate::sync::capture_sync_merge_core_witness(&storage).unwrap(),
committed.database_after,
"stable metadata key {key} must be bound by the merge core witness"
);
storage
.set_metadata(key, &original.expect("stable metadata fixture"))
.unwrap();
assert_eq!(
crate::sync::capture_sync_merge_core_witness(&storage).unwrap(),
committed.database_after,
"restoring stable metadata key {key} must restore the exact core witness"
);
}
}
#[test]
fn sync_merge_intent_prestate_drift_rejects_without_additional_writes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 15, 0).unwrap();
let planned = make_issue(
"bd-merge-planned",
"Planned merge row",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![planned.clone()];
let stale_intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let concurrent = make_issue(
"bd-merge-concurrent",
"Concurrent committed row",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&concurrent, "other-agent").unwrap();
let drifted_prestate = crate::sync::capture_sync_database_witness(&storage).unwrap();
let error = storage
.apply_sync_merge_atomically(&kept, &[], &[], &stale_intent)
.unwrap_err();
assert!(matches!(error, BeadsError::SyncConflict { .. }));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
drifted_prestate
);
assert!(storage.get_issue(&planned.id).unwrap().is_none());
assert!(storage.get_issue(&concurrent.id).unwrap().is_some());
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
}
#[test]
fn sync_merge_payload_validation_rejects_duplicates_and_overlap_without_writes() {
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 30, 0).unwrap();
let duplicate_kept = make_issue(
"bd-merge-duplicate-kept",
"Duplicate kept row",
Status::Open,
2,
None,
now,
None,
);
let mut storage = SqliteStorage::open_memory().unwrap();
let error = assert_sync_merge_payload_rejected_without_writes(
&mut storage,
&[duplicate_kept.clone(), duplicate_kept],
&[],
&[],
);
assert!(matches!(error, BeadsError::Validation { .. }));
let mut storage = SqliteStorage::open_memory().unwrap();
let duplicate_deleted = vec![
"bd-merge-duplicate-deleted".to_string(),
"bd-merge-duplicate-deleted".to_string(),
];
let error = assert_sync_merge_payload_rejected_without_writes(
&mut storage,
&[],
&duplicate_deleted,
&[],
);
assert!(matches!(error, BeadsError::Validation { .. }));
let overlap = make_issue(
"bd-merge-overlap",
"Kept and deleted",
Status::Open,
2,
None,
now,
None,
);
let mut storage = SqliteStorage::open_memory().unwrap();
let error = assert_sync_merge_payload_rejected_without_writes(
&mut storage,
std::slice::from_ref(&overlap),
std::slice::from_ref(&overlap.id),
&[],
);
assert!(matches!(error, BeadsError::Validation { .. }));
let note_target = make_issue(
"bd-merge-duplicate-note",
"Duplicate note target",
Status::Open,
2,
None,
now,
None,
);
let duplicate_notes = vec![
(note_target.id.clone(), "First valid note.".to_string()),
(note_target.id.clone(), "Second valid note.".to_string()),
];
let mut storage = SqliteStorage::open_memory().unwrap();
let error = assert_sync_merge_payload_rejected_without_writes(
&mut storage,
std::slice::from_ref(¬e_target),
&[],
&duplicate_notes,
);
assert!(matches!(error, BeadsError::Validation { .. }));
}
#[test]
fn sync_merge_intent_rejects_every_same_id_issue_payload_substitution_without_writes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 40, 0).unwrap();
let planned = make_issue(
"bd-merge-payload-bound",
"Reviewed payload",
Status::Open,
2,
None,
now,
None,
);
let mut scalar = planned.clone();
scalar.title = "Substituted scalar".to_string();
let mut label = planned.clone();
label.labels.push("substituted-label".to_string());
let mut dependency = planned.clone();
dependency.dependencies.push(crate::model::Dependency {
issue_id: planned.id.clone(),
depends_on_id: "bd-substituted-target".to_string(),
dep_type: crate::model::DependencyType::Related,
created_at: now,
created_by: Some("substituted".to_string()),
metadata: Some("{}".to_string()),
thread_id: None,
});
let mut comment = planned.clone();
comment.comments.push(crate::model::Comment {
id: 991,
issue_id: planned.id.clone(),
author: "substituted".to_string(),
body: "Substituted owned comment".to_string(),
created_at: now,
});
let mut timestamp = planned.clone();
timestamp.updated_at += chrono::Duration::nanoseconds(1);
let mut content_hash = planned.clone();
content_hash.content_hash = Some("substituted-content-hash".to_string());
for (field, substituted) in [
("scalar", scalar),
("label", label),
("dependency", dependency),
("comment", comment),
("timestamp", timestamp),
("content_hash", content_hash),
] {
assert_eq!(substituted.id, planned.id);
assert_sync_merge_substituted_issue_rejected_without_writes(
&mut storage,
&planned,
&substituted,
);
assert!(
storage.get_issue(&planned.id).unwrap().is_none(),
"{field} substitution must not materialize the reviewed ID"
);
}
}
#[test]
fn sync_merge_actor_is_hash_bound_and_invalid_actor_performs_zero_writes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 42, 0).unwrap();
let issue = make_issue(
"bd-merge-actor-bound",
"Actor-bound merge",
Status::Open,
2,
None,
now,
None,
);
let kept = vec![issue];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let mut substituted_actor = intent.clone();
substituted_actor.actor = "different-reviewed-actor".to_string();
assert_ne!(
intent.intent_sha256().unwrap(),
substituted_actor.intent_sha256().unwrap(),
"the actor must be part of the immutable reviewed intent"
);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
let mut invalid_actor = intent;
invalid_actor.actor = " untrimmed".to_string();
let error = storage
.apply_sync_merge_atomically(&kept, &[], &[], &invalid_actor)
.unwrap_err();
assert!(matches!(error, BeadsError::Validation { .. }));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before
);
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
}
#[test]
fn sync_merge_attribution_is_applied_to_all_merge_events_and_consumed_once() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 45, 0).unwrap();
let victim = make_issue(
"bd-merge-attribution-victim",
"Attribution deletion",
Status::Open,
2,
None,
now,
None,
);
let note_target = make_issue(
"bd-merge-attribution-note",
"Attribution note",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&victim, "fixture").unwrap();
storage.create_issue(¬e_target, "fixture").unwrap();
let kept = vec![note_target.clone()];
let deleted = vec![victim.id.clone()];
let notes = vec![(
note_target.id.clone(),
"Reviewed merge attribution note.".to_string(),
)];
storage.set_pending_event_attribution(EventAttribution::new(
Some("agent-merge"),
Some("test-harness"),
Some("test-model"),
None,
));
let intent = sync_merge_test_intent(&storage, &kept, &deleted, ¬es);
storage
.apply_sync_merge_atomically(&kept, &deleted, ¬es, &intent)
.unwrap();
let attributed_merge_events = storage
.get_events(&victim.id, 0)
.unwrap()
.into_iter()
.filter(|event| event.event_type == EventType::Deleted)
.chain(
storage
.get_events(¬e_target.id, 0)
.unwrap()
.into_iter()
.filter(|event| event.event_type == EventType::Commented),
)
.collect::<Vec<_>>();
assert_eq!(attributed_merge_events.len(), 2);
for event in attributed_merge_events {
assert_eq!(event.actor, "merge-agent");
assert_eq!(event.agent_name.as_deref(), Some("agent-merge"));
assert_eq!(event.harness.as_deref(), Some("test-harness"));
assert_eq!(event.model.as_deref(), Some("test-model"));
}
assert!(storage.pending_event_attribution.is_none());
let unrelated = make_issue(
"bd-merge-attribution-next",
"Must not inherit attribution",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&unrelated, "fixture").unwrap();
let created = storage
.get_events(&unrelated.id, 0)
.unwrap()
.into_iter()
.find(|event| event.event_type == EventType::Created)
.unwrap();
assert!(created.agent_name.is_none());
assert!(created.harness.is_none());
assert!(created.model.is_none());
}
#[test]
fn sync_merge_rejects_attribution_and_capacity_policy_drift_without_writes() {
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 50, 0).unwrap();
let kept = vec![make_issue(
"bd-merge-reviewed-context",
"Reviewed merge context",
Status::Open,
2,
None,
now,
None,
)];
let mut attribution_storage = SqliteStorage::open_memory().unwrap();
attribution_storage.set_pending_event_attribution(EventAttribution::new(
Some("reviewed-agent"),
Some("reviewed-harness"),
Some("reviewed-model"),
None,
));
let attribution_intent = sync_merge_test_intent(&attribution_storage, &kept, &[], &[]);
let mut substituted_attribution = attribution_intent.clone();
substituted_attribution.event_attribution = EventAttribution::new(
Some("substituted-agent"),
Some("reviewed-harness"),
Some("reviewed-model"),
None,
);
assert_ne!(
attribution_intent.intent_sha256().unwrap(),
substituted_attribution.intent_sha256().unwrap()
);
attribution_storage
.set_pending_event_attribution(substituted_attribution.event_attribution.clone());
let before = crate::sync::capture_sync_database_witness(&attribution_storage).unwrap();
assert!(matches!(
attribution_storage.apply_sync_merge_atomically(&kept, &[], &[], &attribution_intent),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
crate::sync::capture_sync_database_witness(&attribution_storage).unwrap(),
before
);
assert!(
attribution_storage
.pending_sync_merge_receipt()
.unwrap()
.is_none()
);
let mut policy_storage = SqliteStorage::open_memory().unwrap();
policy_storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let policy_intent = sync_merge_test_intent(&policy_storage, &kept, &[], &[]);
let mut substituted_policy = policy_intent.clone();
substituted_policy.capacity_policy = hard_status_capacity("in_progress", 2);
assert_ne!(
policy_intent.intent_sha256().unwrap(),
substituted_policy.intent_sha256().unwrap()
);
policy_storage.set_workflow_capacity_policy(substituted_policy.capacity_policy);
let before = crate::sync::capture_sync_database_witness(&policy_storage).unwrap();
assert!(matches!(
policy_storage.apply_sync_merge_atomically(&kept, &[], &[], &policy_intent),
Err(BeadsError::SyncConflict { .. })
));
assert_eq!(
crate::sync::capture_sync_database_witness(&policy_storage).unwrap(),
before
);
assert!(
policy_storage
.pending_sync_merge_receipt()
.unwrap()
.is_none()
);
}
#[test]
fn sync_merge_new_kept_rows_obey_group_capacity_and_roll_back_atomically() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.groups.insert(
"active_work".to_string(),
crate::close_policy::CapacityGroup {
statuses: vec!["open".to_string(), "in_progress".to_string()],
soft: None,
hard: Some(0),
},
);
storage.set_workflow_capacity_policy(policy);
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 52, 0).unwrap();
let kept = vec![make_issue(
"bd-merge-capacity-new",
"New merge row must be admitted",
Status::Open,
2,
None,
now,
None,
)];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
assert!(matches!(
storage.apply_sync_merge_atomically(&kept, &[], &[], &intent),
Err(BeadsError::WorkflowCapacityExceeded { .. })
));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before
);
assert!(storage.get_issue(&kept[0].id).unwrap().is_none());
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
assert!(storage.take_capacity_warnings().is_empty());
}
#[test]
fn sync_merge_kept_status_changes_obey_hard_capacity_and_roll_back_atomically() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 53, 0).unwrap();
let existing = make_issue(
"bd-merge-capacity-status",
"Existing row changes status",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&existing, "fixture").unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 0));
let mut changed = storage.get_issue(&existing.id).unwrap().unwrap();
changed.status = Status::InProgress;
let kept = vec![changed];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
assert!(matches!(
storage.apply_sync_merge_atomically(&kept, &[], &[], &intent),
Err(BeadsError::WorkflowCapacityExceeded { .. })
));
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before
);
assert_eq!(
storage.get_issue(&existing.id).unwrap().unwrap().status,
Status::Open
);
assert!(storage.pending_sync_merge_receipt().unwrap().is_none());
}
#[test]
fn sync_merge_kept_status_changes_obey_cross_queue_admission_rules() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 54, 0).unwrap();
let blocker = make_issue(
"bd-merge-capacity-blocker",
"Occupies active-work admission queue",
Status::InProgress,
2,
None,
now,
None,
);
let candidate = make_issue(
"bd-merge-capacity-admission",
"Must satisfy cross-queue admission",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&blocker, "fixture").unwrap();
storage.create_issue(&candidate, "fixture").unwrap();
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.groups.insert(
"active_work".to_string(),
crate::close_policy::CapacityGroup {
statuses: vec!["in_progress".to_string()],
soft: None,
hard: None,
},
);
policy
.admission
.push(crate::close_policy::CapacityAdmissionRule {
name: "review_requires_active_headroom".to_string(),
transitions: crate::close_policy::CapacityTransitionMatcher {
from: vec!["open".to_string()],
to: vec!["in_review".to_string()],
},
require_below: crate::close_policy::CapacityRequirements {
statuses: std::collections::BTreeMap::new(),
groups: std::collections::BTreeMap::from([("active_work".to_string(), 1)]),
},
});
storage.set_workflow_capacity_policy(policy);
let mut changed = storage.get_issue(&candidate.id).unwrap().unwrap();
changed.status = Status::Custom("in_review".to_string());
let kept = vec![changed];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let before = crate::sync::capture_sync_database_witness(&storage).unwrap();
let error = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap_err();
let BeadsError::WorkflowCapacityExceeded { violation } = error else {
panic!("expected a cross-queue policy violation");
};
assert_eq!(violation.capacity_kind, "admission_group");
assert_eq!(violation.capacity_name, "active_work");
assert_eq!(
crate::sync::capture_sync_database_witness(&storage).unwrap(),
before
);
assert_eq!(
storage.get_issue(&candidate.id).unwrap().unwrap().status,
Status::Open
);
}
#[test]
fn sync_merge_capacity_neutral_swap_uses_final_batch_state() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 55, 0).unwrap();
let open = make_issue(
"bd-merge-capacity-swap-open",
"Enters active work",
Status::Open,
2,
None,
now,
None,
);
let active = make_issue(
"bd-merge-capacity-swap-active",
"Drains active work",
Status::InProgress,
2,
None,
now,
None,
);
storage.create_issue(&open, "fixture").unwrap();
storage.create_issue(&active, "fixture").unwrap();
storage.set_workflow_capacity_policy(hard_status_capacity("in_progress", 1));
let mut entering = storage.get_issue(&open.id).unwrap().unwrap();
entering.status = Status::InProgress;
let mut draining = storage.get_issue(&active.id).unwrap().unwrap();
draining.status = Status::Open;
// Deliberately put the admitting row first. Sequential evaluation
// would reject it even though the complete merge is capacity-neutral.
let kept = vec![entering, draining];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let receipt = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
assert_eq!(
storage.get_issue(&open.id).unwrap().unwrap().status,
Status::InProgress
);
assert_eq!(
storage.get_issue(&active.id).unwrap().unwrap().status,
Status::Open
);
assert!(receipt.capacity_warnings.is_empty());
assert!(storage.take_capacity_warnings().is_empty());
}
#[test]
fn sync_merge_capacity_warnings_are_receipt_bound_and_consumable() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 9, 56, 0).unwrap();
let existing = make_issue(
"bd-merge-capacity-warning",
"Crosses a soft threshold",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&existing, "fixture").unwrap();
let mut policy = crate::close_policy::CapacityPolicy::default();
policy.statuses.insert(
"in_progress".to_string(),
crate::close_policy::CapacityLimit {
soft: Some(1),
hard: Some(2),
},
);
storage.set_workflow_capacity_policy(policy);
let mut changed = storage.get_issue(&existing.id).unwrap().unwrap();
changed.status = Status::InProgress;
let kept = vec![changed];
let intent = sync_merge_test_intent(&storage, &kept, &[], &[]);
let receipt = storage
.apply_sync_merge_atomically(&kept, &[], &[], &intent)
.unwrap();
assert_eq!(receipt.capacity_warnings.len(), 1);
assert_eq!(receipt.capacity_warnings[0].issue_id, existing.id);
assert_eq!(receipt.capacity_warnings[0].capacity_kind, "status");
assert_eq!(storage.take_capacity_warnings(), receipt.capacity_warnings);
assert!(storage.take_capacity_warnings().is_empty());
receipt.validate().unwrap();
let mut tampered = receipt.clone();
tampered.capacity_warnings[0].prospective += 1;
assert!(
tampered.validate().is_err(),
"capacity warning evidence must be immutable-envelope bound"
);
}
#[test]
fn sync_merge_deletion_events_match_tombstone_semantics_without_terminal_duplicates() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 7, 27, 10, 0, 0).unwrap();
let open = make_issue(
"bd-merge-delete-open",
"Open deletion",
Status::Open,
2,
None,
now,
None,
);
let mut closed = make_issue(
"bd-merge-delete-closed",
"Closed deletion",
Status::Closed,
2,
None,
now,
None,
);
closed.closed_at = Some(now);
closed.close_reason = Some("completed before merge".to_string());
let already_tombstoned = make_issue(
"bd-merge-delete-tombstone",
"Existing tombstone",
Status::Open,
2,
None,
now,
None,
);
storage.create_issue(&open, "fixture").unwrap();
storage.create_issue(&closed, "fixture").unwrap();
storage
.create_issue(&already_tombstoned, "fixture")
.unwrap();
storage
.delete_issue(
&already_tombstoned.id,
"fixture",
"preexisting deletion",
Some(now),
)
.unwrap();
let deleted = vec![
open.id.clone(),
closed.id.clone(),
already_tombstoned.id.clone(),
];
let intent = sync_merge_test_intent(&storage, &[], &deleted, &[]);
let merge_timestamp = intent.export_as_of;
storage
.apply_sync_merge_atomically(&[], &deleted, &[], &intent)
.unwrap();
let open_tombstone = storage.get_issue(&open.id).unwrap().unwrap();
assert_eq!(open_tombstone.status, Status::Tombstone);
assert_eq!(open_tombstone.deleted_at, Some(merge_timestamp));
assert_eq!(open_tombstone.deleted_by.as_deref(), Some("merge-agent"));
assert_eq!(
open_tombstone.delete_reason.as_deref(),
Some("merge deletion")
);
assert_eq!(
open_tombstone.content_hash.as_deref(),
Some(crate::util::content_hash(&open_tombstone).as_str())
);
let open_deleted = storage
.get_events(&open.id, 0)
.unwrap()
.into_iter()
.filter(|event| event.event_type == EventType::Deleted)
.collect::<Vec<_>>();
assert_eq!(open_deleted.len(), 1);
assert_eq!(open_deleted[0].actor, "merge-agent");
assert_eq!(
open_deleted[0].comment.as_deref(),
Some("Deleted issue: merge deletion")
);
assert_eq!(open_deleted[0].created_at, merge_timestamp);
assert_eq!(
storage
.get_events(&closed.id, 0)
.unwrap()
.iter()
.filter(|event| event.event_type == EventType::Deleted)
.count(),
0
);
assert_eq!(
storage
.get_events(&already_tombstoned.id, 0)
.unwrap()
.iter()
.filter(|event| event.event_type == EventType::Deleted)
.count(),
1
);
}
}