gastownhall/beads · warning · storage.ErrNotFound

%w: issue %s

Error message

%w: issue %s

What it means

This error is a wrapped storage.ErrNotFound returned when a wisp lookup by ID finds no row in the underlying table. scanIssueFromTable runs a SELECT ... WHERE id = ? and when sql.ErrNoRows comes back it wraps storage.ErrNotFound with the offending issue ID so callers can match with errors.Is. It is the library's canonical 'no such issue/wisp' signal, not a database failure.

Source

Thrown at internal/storage/dolt/wisps.go:37

// insertIssueIntoTable delegates to the shared issueops.InsertIssueIntoTable.
func insertIssueIntoTable(ctx context.Context, tx *sql.Tx, table string, issue *types.Issue) error {
	return issueops.InsertIssueIntoTable(ctx, tx, table, issue)
}

// scanIssueFromTable scans a single issue from the specified table.
//
//nolint:gosec // G201: table is a hardcoded constant ("issues" or "wisps")
func scanIssueFromTable(ctx context.Context, db *sql.DB, table, id string) (*types.Issue, error) {
	row := db.QueryRowContext(ctx, fmt.Sprintf(`
		SELECT %s
		FROM %s %s
		WHERE id = ?
	`, issueSelectColumns, table, sqlbuild.LeaseJoin(table)), id)

	issue, err := scanIssueFrom(row)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
	}
	if err != nil {
		return nil, fmt.Errorf("failed to get issue from %s: %w", table, err)
	}
	return issue, nil
}

// generateIssueIDInTable generates a unique ID, checking for collisions
// in the specified table. Supports counter mode for non-ephemeral issues.
//
//nolint:gosec // G201: table is a hardcoded constant
func generateIssueIDInTable(ctx context.Context, tx *sql.Tx, table, prefix string, issue *types.Issue, actor string) (string, error) {
	// Counter mode only applies to the issues table (not wisps).
	if table == "issues" {
		counterMode, err := isCounterModeTx(ctx, tx)
		if err != nil {
			return "", err
		}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Verify the issue ID exists (e.g. bd show <id> or a SELECT on the wisps/issues table) and fix typos
  2. Check errors.Is(err, storage.ErrNotFound) in the caller and handle it as an expected 'missing' path rather than a crash
  3. Confirm you are pointed at the correct database (BEADS_DB / Dolt database) and table scope
  4. If the item should exist, check whether it was closed/deleted or demoted and look up the new ID

Example fix

// before
issue, err := store.GetIssue(ctx, id)
if err != nil {
    return err
}
// after
issue, err := store.GetIssue(ctx, id)
if err != nil {
    if errors.Is(err, storage.ErrNotFound) {
        return fmt.Errorf("issue %s does not exist", id)
    }
    return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

var exists bool
err := db.QueryRow(`SELECT COUNT(*) FROM wisps WHERE id = ?`, id).Scan(&exists)
if err == nil && !exists {
    return fmt.Errorf("issue %s not found", id)
}

Type guard

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

Try / catch

issue, err := store.GetIssue(ctx, id)
switch {
case errors.Is(err, storage.ErrNotFound):
    // handle missing record gracefully
    return nil
case err != nil:
    return fmt.Errorf("lookup %s: %w", id, err)
}

Prevention

When it happens

Trigger: Calling getWisp (directly or via GetIssue/isActiveWisp) with an ID that does not exist in the wisps table; the ID was deleted, demoted/renamed, belongs to the wrong table, or was mistyped.

Common situations: A caller holds a stale ID after the wisp was closed and deleted; scripts referencing an issue from another repo/database; racing with demoteToWispInTx that moved the row; typos in hand-written IDs from CLI usage or automation.

Understand the failure class

Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.

Related errors


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