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
- Choose one style per request: send Replace alone, or send Merge/Set/Unset alone
- Split into two sequential ExecuteUpdate calls if both operations are genuinely needed
- 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
- Decide replacement vs incremental at one call site; don't let callers mix
- Zero out unused MetadataPatch fields when building requests programmatically
- Add a table-driven test covering Replace×Merge/Set/Unset combinations
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
- ExternalDoltConfig: must set Socket or (Host, Port)
- %w: update title: %w
- %w: update priority: %w
- %w: update estimated_minutes: %w
- %w: invalid persistence mode %q
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/68aa649bc1f9652a.
Report an issue: GitHub.