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
- Handle it as an expected outcome: check errors.Is(err, storage.ErrNotFound) and treat as success/idempotent no-op if appropriate.
- Verify the id is correct (bd show <id> before deleting).
- If the id should exist, check for a recent concurrent delete that removed it.
- 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
- Always match with errors.Is(err, storage.ErrNotFound), never string comparison.
- Treat not-found on delete as success for idempotent scripts/retries.
- Verify ids with bd show before bulk deletes built from cached lists.
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.