gastownhall/beads · error · storage.ErrAlreadyExists

%w: %s

Error message

%w: %s

What it means

InsertIssueIfNew wraps storage.ErrAlreadyExists with the issue ID when CreateOnly mode is enabled and a row with the same ID already exists in the target table. CreateOnly means 'insert only, never update', so an existing row is an error rather than an upsert. Note that when ConflictSkip (not CreateOnly) is set, the same situation silently returns instead of erroring.

Source

Thrown at internal/storage/issueops/create.go:684

// an empty notes field must not wipe local notes), but its aux data
// (labels/comments/deps, which never bump updated_at) still merges
// additively (bd-hj85c).
//
//nolint:gosec // G201: table is a hardcoded constant
func InsertIssueIfNew(ctx context.Context, tx DBTX, issueTable string, issue *types.Issue, opts storage.BatchCreateOptions) (isNew bool, staleRejected bool, err error) {
	var existingCount int
	if issue.ID != "" {
		if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ?`, issueTable), issue.ID).Scan(&existingCount); err != nil {
			return false, false, fmt.Errorf("failed to check issue existence for %s: %w", issue.ID, err)
		}
	}
	if opts.ConflictSkip && existingCount > 0 {
		return false, false, nil // issue already exists — skip, never overwrite
	}
	if opts.CreateOnly {
		if err := insertIssueCreateOnly(ctx, tx, issueTable, issue); err != nil {
			if isCreateOnlyDuplicateError(err) {
				return false, false, fmt.Errorf("%w: %s", storage.ErrAlreadyExists, issue.ID)
			}
			return false, false, err
		}
		return true, false, nil
	}
	if opts.RejectStaleUpserts && existingCount > 0 {
		var storedNewer int
		if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ? AND updated_at > ?`, issueTable), issue.ID, issue.UpdatedAt).Scan(&storedNewer); err != nil {
			return false, false, fmt.Errorf("failed to check issue staleness for %s: %w", issue.ID, err)
		}
		if storedNewer > 0 {
			// The conditional ODKU would keep every stored column anyway;
			// skipping the no-op insert makes the rejection observable.
			return false, true, nil
		}
	}
	if err := insertIssueIntoTable(ctx, tx, issueTable, issue, opts.RejectStaleUpserts); err != nil {
		return false, false, fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check existence first (or use errors.Is(err, storage.ErrAlreadyExists)) and skip/merge instead of re-inserting.
  2. Set opts.ConflictSkip instead of CreateOnly if 'skip when present' is the intended behavior.
  3. Drop CreateOnly if an upsert (update existing) is acceptable.
  4. Assign a fresh ID if the new record is genuinely distinct from the existing one.

Example fix

// before
opts := issueops.CreateOpts{CreateOnly: true} // fails if ID exists
_, err := issueops.CreateIssueInTxWithResult(ctx, tx, issue, opts)
// after
if errors.Is(err, storage.ErrAlreadyExists) { return nil } // or:
opts := issueops.CreateOpts{ConflictSkip: true} // skip instead of error
Defensive patterns

Strategy: try-catch

Validate before calling

var existing int
if err := tx.QueryRowContext(ctx,
    "SELECT COUNT(*) FROM issues WHERE id = ?", issue.ID).Scan(&existing); err != nil {
    return err
}
if existing > 0 && opts.CreateOnly {
    return nil // already imported; skip
}

Type guard

func isAlreadyExists(err error) bool {
    return errors.Is(err, storage.ErrAlreadyExists)
}

Try / catch

created, _, err := issueops.InsertIssueIfNew(ctx, tx, table, issue, opts)
if errors.Is(err, storage.ErrAlreadyExists) {
    log.Infow("issue already present, skipping", "id", issue.ID)
    return nil
}
if err != nil { return err }

Prevention

When it happens

Trigger: Calling CreateIssueInTxWithResult or PromoteFromEphemeralInTx with opts.CreateOnly=true while an issue with the same ID already exists in the issues (or wisp) table; e.g. re-running an import in create-only mode, or a promote racing another writer that already inserted the ID.

Common situations: Replaying an import JSONL twice; concurrent agents creating the same auto-generated ID; retrying a partially committed transaction where the row already landed; upgrading tooling that switched from upsert to create-only semantics.

Related errors


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