{"record":{"id":"a4d6fb8505be3d90","repo":"gastownhall/beads","slug":"db-update-s-compare-updates-w","errorCode":null,"errorMessage":"db: Update %s: compare updates: %w","messagePattern":"db: Update (.+?): compare updates: %w","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/domain/db/issue.go","lineNumber":208,"sourceCode":"\t\t\treturn fmt.Errorf(\"db: Update %s: %w\", id, err)\n\t\t}\n\t\tupdates = resolved\n\t}\n\n\t// closed_at coherence parity with issueops.updateIssueInTx: an explicit\n\t// closed_at must agree with the status this update lands, checked against\n\t// the row this unit of work already read and ahead of the close-policy gate\n\t// so a refusal writes nothing at all. It runs on the merge-resolved map\n\t// BEFORE the no-op filter for the same reason it does there — the guard\n\t// reads the caller's intent, and a closed_at equal to the stored value is\n\t// still a request to keep the column, not an absent key.\n\tif err := issueops.ValidateClosedAtCoherence(oldIssue, updates); err != nil {\n\t\treturn fmt.Errorf(\"db: Update %s: %w\", id, err)\n\t}\n\n\tfilteredUpdates, err := issueops.DiscardNoopIssueUpdates(oldIssue, updates)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"db: Update %s: compare updates: %w\", id, err)\n\t}\n\tupdates = filteredUpdates\n\tif len(updates) == 0 {\n\t\treturn nil\n\t}\n\t// A status that matched the row was already dropped as a no-op, so the\n\t// lifecycle side effects below only fire on a real transition.\n\t_, statusChanging := updates[\"status\"]\n\n\t// Close-policy parity with issueops.updateIssueInTx: a status that crosses\n\t// into the done category is a close by another name and answers to close\n\t// policy. A refusal returns before any write and aborts the caller's unit of\n\t// work. The wrap keeps the sentinels matchable, so a caller distinguishes\n\t// these refusals here exactly as it does on the close path.\n\tif statusChanging {\n\t\tcrossing, err := issueops.CrossesIntoDoneCategoryInTx(ctx, r.runner, oldIssue.Status, updates)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"db: Update %s: %w\", id, err)","sourceCodeStart":190,"sourceCodeEnd":226,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/domain/db/issue.go#L190-L226","documentation":"Wrapped failure from Update's no-op comparison step: issueops.DiscardNoopIssueUpdates compares each update against the row already read and errors out instead of silently comparing when something is off (e.g. an incomparable value type or malformed update payload). Message is prefixed 'compare updates' to distinguish it from the other Update wraps.","triggerScenarios":"Calling Update with an updates map whose value types cannot be compared against the stored row (non-string values where strings are expected, unexpected shapes for typed columns), causing DiscardNoopIssueUpdates to return an error rather than a filtered map.","commonSituations":"Dynamic update maps built from JSON/CLI flags where values arrive as any/interface{} of the wrong type; version skew where a caller sends a field with a shape the comparator does not understand.","solutions":["Check the inner error to see which field failed comparison.","Coerce update values to the column's Go type before calling Update (strings for text fields, typed values for timestamps/priority).","If building maps from JSON, decode into typed structs first.","Remove the offending key if the update is genuinely not needed."],"exampleFix":"// before: JSON-decoded any value\nupdates := map[string]any{\"priority\": json.Number(\"1\")}\n// after: typed value\np, _ := json.Number(\"1\").Int64()\nupdates := map[string]any{\"priority\": int(p)}","handlingStrategy":"validation","validationCode":"// coerce values to column Go types before Update\nupdates[\"priority\"] = int(p)         // not json.Number\nupdates[\"title\"] = s                 // plain string\nupdates[\"estimated_minutes\"] = int(m)","typeGuard":"func isComparableScalar(v any) bool {\n    switch v.(type) {\n    case string, int, int64, float64, bool, time.Time, nil:\n        return true\n    }\n    return false\n}","tryCatchPattern":"if err := repo.Update(ctx, id, updates, actor, opts); err != nil {\n    if strings.Contains(err.Error(), \"compare updates\") {\n        // find offending field, fix its Go type, retry once\n    }\n}","preventionTips":["Decode JSON into typed structs instead of map[string]any when building updates","Coerce numeric/timestamp fields to native Go types","Unit-test update maps built from user/CLI input"],"tags":["database","validation","type-mismatch"],"backgroundTag":"update-value-type-mismatch","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}