gastownhall/beads · error · storage.ErrNotFound
%w: issue %s
Error message
%w: issue %s
What it means
getJournalIssueInTx returns storage.ErrNotFound wrapped with the issue ID when neither candidate issue table (issues or deleted issues) contains a snapshot row for the ID. It is the sentinel path for 'this journal event has no issue to snapshot'.
Source
Thrown at internal/storage/issueops/journal.go:411
issue, err := getIssueFromTableInTx(ctx, tx, candidate.issueTable, candidate.labelTable, issueID)
if errors.Is(err, storage.ErrNotFound) {
continue
}
if err != nil {
if optionalBlockedTable(candidate.issueTable) && isTableNotExistError(err) {
continue
}
return nil, err
}
var blocked int
//nolint:gosec // candidate.issueTable is one of the two hardcoded values above.
if err := tx.QueryRowContext(ctx, fmt.Sprintf("SELECT is_blocked FROM %s WHERE id = ?", candidate.issueTable), issueID).Scan(&blocked); err != nil {
return nil, fmt.Errorf("journal: read is_blocked for %s: %w", issueID, err)
}
issue.IsBlocked = blocked != 0
return issue, nil
}
return nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, issueID)
}
// insertEventRow performs the actual INSERT. It is the ONE seam both write
// plumbings funnel through, so the seq mechanism cannot drift between them. A
// nil issue is stored as SQL NULL (deletes); a nil dep is stored as SQL NULL
// (non-dependency ops). ts is the insert time, stamped inside the committing
// transaction. actor is stored as-is — "" for the genuinely unattributable
// paths — in the NOT NULL DEFAULT ” actor column.
func insertEventRow(ctx context.Context, tx DBTX, op EventOp, issueID string, issue *types.Issue, dep *EventDep, comment *EventComment, actor string) error {
var issueJSON any
if issue != nil {
b, err := json.Marshal(issue)
if err != nil {
return fmt.Errorf("journal: marshal issue %s: %w", issueID, err)
}
issueJSON = string(b)
}
var depJSON anyView on GitHub (pinned to 71377f2769)
Solutions
- Verify the issue exists: bd show <id> before mutating
- Create the issue first if it never existed
- Check for concurrent deletion; re-sync or skip the event
- errors.Is(err, storage.ErrNotFound) to branch on this case explicitly
Example fix
// before
if err := ops.RecordEventInTx(ctx, tx, op, id, nil, nil); err != nil { return err }
// after
if err := ops.RecordEventInTx(ctx, tx, op, id, nil, nil); err != nil {
if errors.Is(err, storage.ErrNotFound) { return fmt.Errorf("skipping %s: issue gone", id) }
return err
} Defensive patterns
Strategy: type-guard
Validate before calling
var exists bool
_ = tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM issues WHERE id = ?)", id).Scan(&exists)
if !exists { return fmt.Errorf("cannot journal unknown issue %s", id) } Type guard
func isIssueNotFound(err error) bool {
return errors.Is(err, storage.ErrNotFound)
} Try / catch
if err := ops.RecordEventInTx(ctx, tx, op, id, nil, nil); err != nil {
if isIssueNotFound(err) { return skipEvent(id) }
return err
} Prevention
- Validate IDs with bd show before scripting mutations
- Handle deleted issues explicitly in sync/import jobs
- Use IDs returned by the API, never fabricated ones
When it happens
Trigger: Recording any journal event (RecordEventInTx, RecordDepEventInTx, RecordCommentEventInTx) for an issue ID absent from both issues and the tombstone table — e.g. the issue was never created, was purged, or the ID is mistyped.
Common situations: Sync/import scripts referencing IDs from another database; commenting on or updating an issue deleted by another worker; tests using fabricated IDs; cross-repo ID confusion after re-cloning.
Related errors
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/1d47fd413493ec3f.
Report an issue: GitHub.