gastownhall/beads · warning · storage.ErrNotFound

%w: issue %s

Error message

%w: issue %s

What it means

Not a crash: this is the deliberate not-found return. When the DELETE affected zero rows, deleteIssueRowInTx returns storage.ErrNotFound wrapped with the issue id, so callers can use errors.Is(err, storage.ErrNotFound) — the same contract as GetIssue/UpdateIssue. It means the id did not exist (already deleted, wrong id, or it was in the other table than routed to).

Source

Thrown at internal/storage/issueops/delete.go:68

	return nil
}

//nolint:gosec // G201: table names come from WispTableRouting (hardcoded constants)
func deleteIssueRowInTx(ctx context.Context, tx *sql.Tx, id string, isWisp bool) error {
	issueTable, _, _, _ := WispTableRouting(isWisp)
	result, err := tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE id = ?", issueTable), id)
	if err != nil {
		return fmt.Errorf("delete issue from %s: %w", issueTable, err)
	}
	rows, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("get rows affected: %w", err)
	}
	if rows == 0 {
		// Wrap the sentinel so callers can errors.Is(..., storage.ErrNotFound),
		// matching GetIssue/UpdateIssue. The storage conformance suite asserts
		// this parity across not-found paths.
		return fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
	}
	// Journal the delete in the same transaction. This worker backs single
	// deletes (DeleteIssueInTx) and the per-wisp branch of the bulk delete
	// (DeleteResolvedSetInTx); the bulk regular-issue branch journals its own
	// ids directly. The rows==0 return above is what keeps this
	// actually-deleted-only. The delete plumbing (storage.DeleteIssue and the
	// bulk/cascade resolvers) carries no actor, so the row records none.
	if err := RecordDeleteInTx(ctx, tx, id, ""); err != nil {
		return err
	}
	if isWisp {
		if err := DeleteWispFromDependenciesInTx(ctx, tx, id); err != nil {
			return err
		}
	} else if err := DeleteLeaseInTx(ctx, tx, id); err != nil {
		// A deleted issue holds no lease.
		return err
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Handle it as an expected outcome: check errors.Is(err, storage.ErrNotFound) and treat as success/idempotent no-op if appropriate.
  2. Verify the id is correct (bd show <id> before deleting).
  3. If the id should exist, check for a recent concurrent delete that removed it.
  4. Re-run 'bd doctor' if you suspect wisp-vs-regular routing confusion for a fresh issue.

Example fix

// before
if err := storage.DeleteIssue(ctx, db, id); err != nil { return err }
// after: treat not-found as idempotent success
if err := storage.DeleteIssue(ctx, db, id); err != nil && !errors.Is(err, storage.ErrNotFound) {
	return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// before deleting, confirm the id exists
if _, err := storage.GetIssue(ctx, db, id); err != nil {
	if errors.Is(err, storage.ErrNotFound) { return nil } // nothing to delete
	return err
}

Type guard

func isIssueNotFound(err error) bool {
	return errors.Is(err, storage.ErrNotFound)
}

Try / catch

if err := storage.DeleteIssue(ctx, db, id); err != nil {
	if isIssueNotFound(err) {
		return nil // idempotent: already deleted
	}
	return err
}

Prevention

When it happens

Trigger: Deleting an id that was already deleted (double-delete or a race between two concurrent deletes); passing a typo'd or stale id; an id that lives in the wisp table being routed as a regular issue or vice versa.

Common situations: UI retry after a first delete already succeeded; scripts operating on an exported/cached issue list that is out of date; deleting an id that was never created in this database.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/81a9fb72a75ed7eb. Report an issue: GitHub.