gastownhall/beads · error · storage.ErrValidation

%w: cannot combine metadata replacement with incremental met

Error message

%w: cannot combine metadata replacement with incremental metadata edits

What it means

ValidateMetadataPatch rejects metadata updates that try to combine full replacement (Replace.Set) with incremental edits (Merge.Set, Set entries, or Unset entries) in the same patch. The two styles are mutually exclusive because their semantics conflict, and the validation runs before ExecuteUpdate writes anything. It wraps storage.ErrValidation.

Source

Thrown at internal/storage/issueops/aggregate.go:104

		if err := types.ValidateIssuePriority(patch.Priority.Value); err != nil {
			return fmt.Errorf("%w: update priority: %w", storage.ErrValidation, err)
		}
	}
	if patch.EstimatedMinutes.Set {
		if err := types.ValidateIssueEstimatedMinutes(patch.EstimatedMinutes.Value); err != nil {
			return fmt.Errorf("%w: update estimated_minutes: %w", storage.ErrValidation, err)
		}
	}
	if patch.Persistence.Set && !patch.Persistence.Value.IsValid() {
		return fmt.Errorf("%w: invalid persistence mode %q", storage.ErrValidation, patch.Persistence.Value)
	}
	return nil
}

// ValidateMetadataPatch checks mutually exclusive metadata edits.
func ValidateMetadataPatch(patch publicops.MetadataPatch) error {
	if patch.Replace.Set && (patch.Merge.Set || len(patch.Set) > 0 || len(patch.Unset) > 0) {
		return fmt.Errorf("%w: cannot combine metadata replacement with incremental metadata edits", storage.ErrValidation)
	}
	return nil
}

// ValidateScalarUpdates checks typed scalar values before they reach SQL.
func ValidateScalarUpdates(ctx context.Context, tx DBTX, updates map[string]interface{}) error {
	if rawType, ok := updates["issue_type"]; ok {
		var issueType types.IssueType
		switch value := rawType.(type) {
		case types.IssueType:
			issueType = value
		case string:
			issueType = types.IssueType(value)
		default:
			return fmt.Errorf("%w: invalid issue type %v", storage.ErrValidation, rawType)
		}
		customTypes, err := ResolveCustomTypesInTx(ctx, tx)
		if err != nil {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Choose one style per request: send Replace alone, or send Merge/Set/Unset alone
  2. Split into two sequential ExecuteUpdate calls if both operations are genuinely needed
  3. Reset the unused fields of MetadataPatch to zero values before building the request

Example fix

// before
patch := publicops.MetadataPatch{Replace: setField(m), Set: map[string]string{"k":"v"}} // conflict
// after
patch := publicops.MetadataPatch{Replace: setField(m)} // or use Set only, not both
Defensive patterns

Strategy: validation

Validate before calling

func validMetadataPatch(p publicops.MetadataPatch) bool {
  if p.Replace.Set && (p.Merge.Set || len(p.Set) > 0 || len(p.Unset) > 0) {
    return false
  }
  return true
}

Type guard

func isReplacementOnly(p publicops.MetadataPatch) bool {
  return p.Replace.Set && !p.Merge.Set && len(p.Set) == 0 && len(p.Unset) == 0
}

Try / catch

if err := issueops.ValidateMetadataPatch(patch); errors.Is(err, storage.ErrValidation) {
  // split into two calls: one Replace, then one incremental
}

Prevention

When it happens

Trigger: ExecuteUpdate with a MetadataPatch where Replace.Set=true and any of: Merge.Set=true, len(patch.Set)>0, or len(patch.Unset)>0.

Common situations: UI code that always populates a merge/set map for other fields while a separate code path turns on Replace; merging two partial patches client-side so both Replace and Set end up set; copy-pasted request builders combining doc examples of both styles.

Related errors


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