aven-core 0.1.32

Core library for the Aven local-first task manager
Documentation
use anyhow::{Result, bail, ensure};
use sqlx::SqliteConnection;
use tracing::info;

use crate::change_log::op_type;
use crate::db::{begin_immediate, insert_change, set_field_version};
use crate::error::CoreError;
use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
use crate::projects::resolve_project_for_stored_value;
use crate::refs::get_task_in_workspace;
use crate::task_fields::TaskField;
use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
use crate::workspaces::Workspace;

use super::{
    ConflictOutcome, ConflictResolutionOutcome, ConflictResolutionValue, ExpectedConflictIdentity,
};

pub(super) async fn resolve_conflict_value(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: &str,
    resolution: ConflictResolutionValue<'_>,
    expected: Option<ExpectedConflictIdentity<'_>>,
    tui_summary: Option<&str>,
) -> Result<ConflictResolutionOutcome> {
    if let Some(field_id) = field.strip_prefix("metadata:") {
        ensure!(
            tui_summary.is_none(),
            "error metadata-conflicts-not-supported-in-tui"
        );
        return super::metadata::resolve_metadata_conflict_value(
            conn,
            workspace,
            task_id,
            field,
            field_id.parse()?,
            resolution,
        )
        .await;
    }
    let task_field = TaskField::parse_or_unknown(field)?;
    let field = task_field.as_str();
    let mut tx = begin_immediate(conn).await?;
    let before = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
    let conflict_id = crate::undo::conflict_row_id(&mut tx, &workspace.id, task_id, field).await?;
    let conflict = sqlx::query_as::<_, (String, String, String, String)>(
        "SELECT local_value, remote_value, variant_a, variant_b FROM conflicts
         WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
    )
    .bind(&workspace.id)
    .bind(task_id)
    .bind(field)
    .fetch_optional(&mut *tx)
    .await?
    .ok_or_else(|| {
        CoreError::not_found(format!(
            "error conflict-not-found task_id={task_id} field={field}"
        ))
    })?;
    if let Some(expected) = expected
        && (conflict.2 != expected.variant_a || conflict.3 != expected.variant_b)
    {
        return Err(CoreError::generation_conflict(format!(
            "error stale-conflict task_id={task_id} field={field}"
        ))
        .into());
    }
    let value = match resolution {
        ConflictResolutionValue::Explicit(value) => value.to_string(),
        ConflictResolutionValue::Local => conflict.0,
        ConflictResolutionValue::Remote => conflict.1,
    };
    if task_field == TaskField::IsEpic
        && value == "0"
        && crate::operations::task_has_epic_children(&mut tx, &workspace.id, task_id).await?
    {
        bail!("error epic-has-children task_id={task_id}");
    }
    let affected_attachment_hashes: Vec<String> = if task_field == TaskField::Deleted {
        sqlx::query_scalar(
            "SELECT DISTINCT sha256 FROM task_attachments
             WHERE workspace_id = ? AND task_id = ?",
        )
        .bind(&workspace.id)
        .bind(task_id)
        .fetch_all(&mut *tx)
        .await?
    } else {
        Vec::new()
    };
    let result = sqlx::query(
        "UPDATE conflicts SET resolved = 1 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
    )
    .bind(&workspace.id)
    .bind(task_id)
    .bind(field)
    .execute(&mut *tx)
    .await?;
    if result.rows_affected() != 1 {
        return Err(CoreError::not_found(format!(
            "error conflict-not-found task_id={task_id} field={field}"
        ))
        .into());
    }
    let payload = if task_field.is_project() {
        let project = resolve_project_for_stored_value(&mut tx, &workspace.id, &value).await?;
        apply_project_id_in_workspace(&mut tx, &workspace.id, task_id, &project.id).await?;
        TaskField::project_payload(&workspace.id, &workspace.key, &project)
    } else {
        apply_field_value_in_workspace(&mut tx, &workspace.id, task_id, field, &value).await?;
        task_field.scalar_payload(&workspace.id, &workspace.key, &value)?
    };
    let change_id = insert_change(
        &mut tx,
        "task",
        task_id,
        Some(field),
        op_type::RESOLVE_FIELD,
        payload,
        None,
    )
    .await?;
    set_field_version(&mut tx, task_id, field, &change_id).await?;
    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
        &mut tx,
        &affected_attachment_hashes,
        &crate::attachments::lifecycle::SystemClock,
    )
    .await?;
    let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
    let after = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
    if let Some(summary) = tui_summary {
        record_tui_undo(
            &mut tx,
            &workspace.id,
            summary,
            UndoPayload {
                commands: vec![UndoCommand::RestoreConflictResolution {
                    task_id: task_id.clone(),
                    field: field.to_string(),
                    before: before.clone(),
                    after: after.clone(),
                    conflict_id,
                }],
            },
        )
        .await?;
    }
    tx.commit().await?;
    info!(task_id = %task_id, field = %field, "conflict resolved");
    Ok(ConflictResolutionOutcome {
        outcome: ConflictOutcome {
            task,
            field: field.to_string(),
        },
        before,
        after,
        conflict_id,
    })
}