Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions internal/store/delete_cancelled_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package store

import (
"errors"
"testing"

"github.com/RandomCodeSpace/kb/internal/board"
)

func TestDeleteCancelledTaskEnforcesStatusInsideTheWrite(t *testing.T) {
s := newStore(t)
live, err := s.AddTask("alice", board.Task{Title: "Still live", Status: board.StatusTodo})
if err != nil {
t.Fatal(err)
}
if _, err := s.DeleteCancelledTask("alice", live.ID); !errors.Is(err, ErrTaskNotCancelled) {
t.Fatalf("DeleteCancelledTask(live) = %v, want ErrTaskNotCancelled", err)
}
if _, err := s.UpdateTask("alice", live.ID, TaskPatch{}); err != nil {
t.Fatalf("refused purge removed live task: %v", err)
}

cancelled, err := s.MoveTask("alice", live.ID, board.StatusCancelled)
if err != nil {
t.Fatal(err)
}
if err := s.RecordTombstone("alice", cancelled.ID, "superseded"); err != nil {
t.Fatal(err)
}
deleted, err := s.DeleteCancelledTask("alice", cancelled.ID)
if err != nil || deleted.ID != cancelled.ID {
t.Fatalf("DeleteCancelledTask(cancelled) = %+v, %v", deleted, err)
}
if _, err := s.UpdateTask("alice", cancelled.ID, TaskPatch{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("purged task lookup = %v, want ErrNotFound", err)
}
if _, found, err := s.Tombstone("alice", cancelled.ID); err != nil || found {
t.Fatalf("purged tombstone = found %v, err %v", found, err)
}
}

func TestCancelTaskMovesAndTombstonesAtomically(t *testing.T) {
s := newStore(t)
task, err := s.AddTask("alice", board.Task{Title: "Reject me", Status: board.StatusTodo})
if err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`CREATE TRIGGER fail_atomic_tombstone BEFORE INSERT ON tombstones BEGIN SELECT RAISE(ABORT, 'no tombstone'); END`); err != nil {
t.Fatal(err)
}
reason := "superseded"
if _, err := s.CancelTask("alice", task.ID, &reason); err == nil {
t.Fatal("CancelTask succeeded while tombstone insert failed")
}
current, err := s.Task("alice", task.ID)
if err != nil || current.Status != board.StatusTodo {
t.Fatalf("failed atomic cancel persisted status = %s, %v", current.Status, err)
}
if _, found, err := s.Tombstone("alice", task.ID); err != nil || found {
t.Fatalf("failed atomic cancel persisted tombstone = %v, %v", found, err)
}
if _, err := s.db.Exec(`DROP TRIGGER fail_atomic_tombstone`); err != nil {
t.Fatal(err)
}

cancelled, err := s.CancelTask("alice", task.ID, &reason)
if err != nil || cancelled.Status != board.StatusCancelled {
t.Fatalf("CancelTask = %+v, %v", cancelled, err)
}
tombstone, found, err := s.Tombstone("alice", task.ID)
if err != nil || !found || tombstone.Reason != reason {
t.Fatalf("atomic tombstone = %+v, %v, %v", tombstone, found, err)
}
}

func TestUpdateAndMoveTaskIfFieldsMatchIsAtomic(t *testing.T) {
s := newStore(t)
original := []board.Check{{Text: "only"}}
task, err := s.AddTask("alice", board.Task{Title: "CAS move", Status: board.StatusTodo, Checks: original})
if err != nil {
t.Fatal(err)
}
doneChecks := []board.Check{{Text: "only", Done: true}}
target := board.StatusDone
index := 0

stale := []board.Check{{Text: "stale"}}
if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID,
TaskPatch{Checks: &stale}, TaskPatch{Checks: &doneChecks}, &target, &index, nil); err == nil {
t.Fatal("stale field match succeeded")
} else {
var conflict *TaskFieldsConflictError
if !errors.As(err, &conflict) {
t.Fatalf("stale field match = %T %v", err, err)
}
}

guardErr := errors.New("guard refused")
if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID,
TaskPatch{Checks: &original}, TaskPatch{Checks: &doneChecks}, &target, &index,
func(board.Task) error { return guardErr }); !errors.Is(err, guardErr) {
t.Fatalf("guarded CAS move = %v", err)
}
unchanged, err := s.Task("alice", task.ID)
if err != nil || unchanged.Status != board.StatusTodo || unchanged.Checks[0].Done {
t.Fatalf("refused CAS move persisted = %+v, %v", unchanged, err)
}

moved, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID,
TaskPatch{Checks: &original}, TaskPatch{Checks: &doneChecks}, &target, &index, nil)
if err != nil || moved.Status != board.StatusDone || !moved.Checks[0].Done {
t.Fatalf("CAS move = %+v, %v", moved, err)
}

invalidStatus := board.Status("invalid")
if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID,
TaskPatch{}, TaskPatch{}, &invalidStatus, nil, nil); err == nil {
t.Fatal("invalid CAS move status succeeded")
}
negative := -1
if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID,
TaskPatch{}, TaskPatch{}, nil, &negative, nil); err == nil {
t.Fatal("negative CAS move index succeeded")
}
if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", "missing",
TaskPatch{}, TaskPatch{}, nil, nil, nil); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing CAS move = %v", err)
}
}

func TestRestorePreservesContextAndPurgeRemovesIt(t *testing.T) {

Check failure on line 131 in internal/store/delete_cancelled_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaASyaFbROXJ_dC3gA6E&open=AaASyaFbROXJ_dC3gA6E&pullRequest=109
s := newStore(t)
target, err := s.AddTask("alice", board.Task{Title: "Target", Status: board.StatusTodo})
if err != nil {
t.Fatal(err)
}
blocker, err := s.AddTask("alice", board.Task{Title: "Blocker", Status: board.StatusTodo})
if err != nil {
t.Fatal(err)
}
blocked, err := s.AddTask("alice", board.Task{Title: "Blocked", Status: board.StatusTodo})
if err != nil {
t.Fatal(err)
}
if _, _, err := s.Link("alice", target.ID, blocked.ID); err != nil {
t.Fatal(err)
}
if _, _, err := s.Link("alice", blocker.ID, target.ID); err != nil {
t.Fatal(err)
}
if _, err := s.AddComment("alice", target.ID, "alice", "keep this context"); err != nil {
t.Fatal(err)
}
reason := "not now"
if _, err := s.CancelTask("alice", target.ID, &reason); err != nil {
t.Fatal(err)
}
restored, err := s.MoveTask("alice", target.ID, board.StatusTodo)
if err != nil || restored.Status != board.StatusTodo {
t.Fatalf("restore = %+v, %v", restored, err)
}
comments, err := s.Comments("alice", target.ID)
if err != nil || len(comments) != 1 {
t.Fatalf("restore comments = %d, %v", len(comments), err)
}
links, err := s.TaskLinks("alice", target.ID)
if err != nil || len(links.Blocks) != 1 || len(links.BlockedBy) != 1 {
t.Fatalf("restore links = %+v, %v", links, err)
}
if _, found, err := s.Tombstone("alice", target.ID); err != nil || found {
t.Fatalf("restore retained tombstone = %v, %v", found, err)
}

if _, err := s.CancelTask("alice", target.ID, &reason); err != nil {
t.Fatal(err)
}
if _, err := s.DeleteCancelledTask("alice", target.ID); err != nil {
t.Fatal(err)
}
for table, query := range map[string]string{
"comments": `SELECT COUNT(*) FROM comments WHERE scope='alice' AND task_id=?`,
"links": `SELECT COUNT(*) FROM task_links WHERE scope='alice' AND (blocker_id=? OR blocked_id=?)`,
"tombstones": `SELECT COUNT(*) FROM tombstones WHERE scope='alice' AND task_id=?`,
} {
var count int
var queryErr error
if table == "links" {
queryErr = s.db.QueryRow(query, target.ID, target.ID).Scan(&count)
} else {
queryErr = s.db.QueryRow(query, target.ID).Scan(&count)
}
if queryErr != nil || count != 0 {
t.Errorf("purge %s rows = %d, %v", table, count, queryErr)
}
}
}
34 changes: 19 additions & 15 deletions internal/store/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,14 @@ func (s *Store) RecordTombstone(scope, taskID, reason string) error {
if err := validateTombstoneReason(reason); err != nil {
return err
}
killedAt := time.Now().UTC().Format(time.RFC3339Nano)
return s.withTx(func(tx *sql.Tx) error {
result, err := tx.Exec(`
return recordTombstoneTx(tx, scope, taskID, reason)
})
}

func recordTombstoneTx(tx *sql.Tx, scope, taskID, reason string) error {
killedAt := time.Now().UTC().Format(time.RFC3339Nano)
result, err := tx.Exec(`
INSERT INTO tombstones (scope, task_id, reason, killed_at)
SELECT ?, ?, ?, ?
WHERE EXISTS (
Expand All @@ -270,19 +275,18 @@ func (s *Store) RecordTombstone(scope, taskID, reason string) error {
ON CONFLICT(scope, task_id) DO UPDATE SET
reason = excluded.reason,
killed_at = excluded.killed_at`,
scope, taskID, reason, killedAt, scope, taskID)
if err != nil {
return fmt.Errorf("store: record tombstone: %w", err)
}
written, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("store: inspect tombstone write: %w", err)
}
if written != 1 {
return ErrTombstoneTaskNotCancelled
}
return nil
})
scope, taskID, reason, killedAt, scope, taskID)
if err != nil {
return fmt.Errorf("store: record tombstone: %w", err)
}
written, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("store: inspect tombstone write: %w", err)
}
if written != 1 {
return ErrTombstoneTaskNotCancelled
}
return nil
}

// Tombstone returns the scoped graveyard reason for taskID when one exists.
Expand Down
90 changes: 87 additions & 3 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ import (

// Sentinel errors for task ID prefix resolution.
var (
ErrNotFound = errors.New("task not found")
ErrAmbiguous = errors.New("ambiguous task id prefix")
ErrInvalidTaskIDs = errors.New("invalid canonical task ids")
ErrNotFound = errors.New("task not found")
ErrAmbiguous = errors.New("ambiguous task id prefix")
ErrInvalidTaskIDs = errors.New("invalid canonical task ids")
ErrTaskNotCancelled = errors.New("task is not cancelled")
)

// RevisionConflictError reports a failed board compare-and-swap. Revisions
Expand Down Expand Up @@ -1019,6 +1020,44 @@ func (s *Store) UpdateTaskIfFieldsMatch(user, idPrefix string, expected, patch T
return out, nil
}

// UpdateAndMoveTaskIfFieldsMatch applies patch and move only when every
// non-nil expected field still matches. The comparison, patch, guard, and move
// share one transaction, so a stale modal cannot overwrite a concurrent edit.
func (s *Store) UpdateAndMoveTaskIfFieldsMatch(
user, idPrefix string,
expected, patch TaskPatch,
moveTo *board.Status,
index *int,
guard func(board.Task) error,
) (board.Task, error) {
if moveTo != nil && !moveTo.Valid() {
return board.Task{}, fmt.Errorf("store: invalid status %q", *moveTo)
}
if index != nil && *index < 0 {
return board.Task{}, fmt.Errorf("store: invalid index %d", *index)
}
var out board.Task
err := s.withTx(func(tx *sql.Tx) error {
id, err := resolveID(tx, user, idPrefix)
if err != nil {
return err
}
current, err := getTask(tx, user, id)
if err != nil {
return err
}
if fields := taskFieldConflicts(current, expected); len(fields) > 0 {
return &TaskFieldsConflictError{Fields: fields}
}
out, err = s.updateAndMoveTaskTx(tx, user, id, patch, moveTo, index, guard)
return err
})
if err != nil {
return board.Task{}, err
}
return out, nil
}

func taskFieldConflicts(task board.Task, expected TaskPatch) []string {
fields := make([]string, 0, 9)
if expected.Emoji != nil && task.Emoji != *expected.Emoji {
Expand Down Expand Up @@ -1239,6 +1278,36 @@ func (s *Store) MoveTask(user, idPrefix string, to board.Status) (board.Task, er
return s.UpdateAndMoveTask(user, idPrefix, TaskPatch{}, &to, nil, nil)
}

// CancelTask soft-deletes a task and optionally records its kill reason in one
// transaction. The status transition happens before the tombstone insert, as
// required by the tombstone invariant, but neither write can escape alone.
func (s *Store) CancelTask(user, idPrefix string, reason *string) (board.Task, error) {
if reason != nil {
trimmed := strings.TrimSpace(*reason)
if err := validateTombstoneReason(trimmed); err != nil {
return board.Task{}, err
}
reason = &trimmed
}
var out board.Task
err := s.withTx(func(tx *sql.Tx) error {
cancelled := board.StatusCancelled
var err error
out, err = s.updateAndMoveTaskTx(tx, user, idPrefix, TaskPatch{}, &cancelled, nil, nil)
if err != nil {
return err
}
if reason != nil {
return recordTombstoneTx(tx, user, out.ID, *reason)
}
return nil
})
if err != nil {
return board.Task{}, err
}
return out, nil
}

// repositionTask splices id into column st at index, clamped to the column
// length, and rewrites that column's positions to 0..n-1. Columns hold a
// handful of tasks, so a full rewrite is cheaper to reason about than sparse
Expand Down Expand Up @@ -1322,6 +1391,18 @@ func moveTask(tx *sql.Tx, user string, t board.Task, to board.Status) (board.Tas

// DeleteTask removes the task matching idPrefix and returns it.
func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error) {
return s.deleteTask(user, idPrefix, false)
}

// DeleteCancelledTask permanently deletes a task only while it is in the
// Cancelled column. This is the direct-store hard-delete seam used by the TUI:
// checking the status and removing the row happen in one transaction, so a
// concurrent restore cannot race an already-confirmed purge.
func (s *Store) DeleteCancelledTask(user, idPrefix string) (board.Task, error) {
return s.deleteTask(user, idPrefix, true)
}

func (s *Store) deleteTask(user, idPrefix string, requireCancelled bool) (board.Task, error) {
var out board.Task
err := s.withTx(func(tx *sql.Tx) error {
id, err := resolveID(tx, user, idPrefix)
Expand All @@ -1332,6 +1413,9 @@ func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error) {
if err != nil {
return err
}
if requireCancelled && t.Status != board.StatusCancelled {
return ErrTaskNotCancelled
}
if _, err := tx.Exec(`DELETE FROM tasks WHERE user = ? AND id = ?`, user, id); err != nil {
return fmt.Errorf("store: delete task: %w", err)
}
Expand Down
Loading
Loading