Make the Go/SQL tally parity a tested contract, not a hand-maintained one
Build image / build-and-push (push) Successful in 5s

Gadfly's point stands: countRevisions reproduces in Go what ListChangeSets does
in SQL, and nothing was holding the two together. Its sibling finding is the
same problem seen from the test side — the test compared only TOTALS, which
would agree even if the two groupings had diverged completely.

Both ends now name the contract, and the test compares the full per-(entity, op)
breakdown row for row, across two reverts of different shapes so there is more
than one row to get wrong. That turns "someone will remember to keep these in
step" into something CI notices.

The duplication itself stays. A change set that has just been written has no
rows to GROUP BY yet, so the alternative to counting in Go is a second round
trip to count what we are already holding.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 08:25:00 -04:00
co-authored by Claude Opus 4.8
parent 42246fd6b7
commit 8a0f367804
3 changed files with 76 additions and 23 deletions
+7 -3
View File
@@ -346,9 +346,13 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
return cs, conflicts, nil return cs, conflicts, nil
} }
// countRevisions tallies revisions by (entity type, op), matching what the // countRevisions tallies revisions by (entity type, op).
// history list read produces so both sides of the API describe a change set the //
// same way. // This deliberately reproduces in Go what ListChangeSets does in SQL, because a
// change set that has just been written has no rows to GROUP BY yet — the
// alternative is a second round trip to count what we are already holding.
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
// compares the two breakdowns row for row so it can't drift silently.
func countRevisions(revs []domain.Revision) []domain.ChangeCount { func countRevisions(revs []domain.Revision) []domain.ChangeCount {
type key struct{ entityType, op string } type key struct{ entityType, op string }
seen := map[key]int{} seen := map[key]int{}
+65 -18
View File
@@ -712,6 +712,11 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
// from "the tally wasn't loaded", and the chat panel read that ambiguity the // from "the tally wasn't loaded", and the chat panel read that ambiguity the
// wrong way: a successful undo reported "nothing left to undo", which is the // wrong way: a successful undo reported "nothing left to undo", which is the
// worst possible thing to say about an action that just worked. // worst possible thing to say about an action that just worked.
//
// It also pins the cross-layer contract that fix created. countRevisions tallies
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
// the totals, which would agree even if the groupings had diverged.
func TestRevertResultCarriesItsCounts(t *testing.T) { func TestRevertResultCarriesItsCounts(t *testing.T) {
s := newTestService(t, openConfig()) s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]") owner := seedUser(t, s, "[email protected]")
@@ -723,35 +728,77 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
fill := history(t, s, owner, g.ID)[0] // A second, different kind of change, so the breakdown has more than one row
planted := totalOf(fill.Counts) // to get wrong.
if planted == 0 { if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
t.Fatal("the fill recorded no counts") t.Fatalf("rename: %v", err)
}
sets := history(t, s, owner, g.ID)
rename, fill := sets[0], sets[1]
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
}
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
} }
undo, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI) for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
if undo == nil { if undo == nil {
t.Fatal("a revert that did work returned no change set") t.Fatal("a revert that did work returned no change set")
} }
if got := totalOf(undo.Counts); got != planted { if len(undo.Counts) == 0 {
t.Errorf("revert reported %d changes, want %d — an empty tally reads as 'nothing happened'", got, planted) t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
}
}
// Undoing a fill deletes every plop it created, so the tallies must match.
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
} }
// And the list read agrees with what the revert returned, so both halves of // The SQL tally and the Go tally must agree, row for row.
// the API describe the same change set the same way. listed := history(t, s, owner, g.ID)
listed := history(t, s, owner, g.ID)[0] byID := map[int64][]domain.ChangeCount{}
if listed.ID != undo.ID { for _, cs := range listed {
t.Fatalf("newest change set is %d, want the revert %d", listed.ID, undo.ID) byID[cs.ID] = cs.Counts
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
fromSQL, ok := byID[undo.ID]
if !ok {
t.Fatalf("revert %d is missing from the history list", undo.ID)
}
if !sameCounts(undo.Counts, fromSQL) {
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
undo.ID, undo.Counts, fromSQL)
} }
if totalOf(listed.Counts) != totalOf(undo.Counts) {
t.Errorf("list says %d changes, revert said %d", totalOf(listed.Counts), totalOf(undo.Counts))
} }
} }
func totalOf(counts []domain.ChangeCount) int { // sameCounts compares two tallies regardless of order.
func sameCounts(a, b []domain.ChangeCount) bool {
if len(a) != len(b) {
return false
}
index := func(cs []domain.ChangeCount) map[string]int {
m := map[string]int{}
for _, c := range cs {
m[c.EntityType+"/"+c.Op] = c.N
}
return m
}
ia, ib := index(a), index(b)
for k, v := range ia {
if ib[k] != v {
return false
}
}
return true
}
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
func countsTotal(counts []domain.ChangeCount) int {
n := 0 n := 0
for _, c := range counts { for _, c := range counts {
n += c.N n += c.N
+3 -1
View File
@@ -119,7 +119,9 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
return sets, nil return sets, nil
} }
// One grouped query for the whole page rather than N per-row counts. // One grouped query for the whole page rather than N per-row counts. The
// service's countRevisions mirrors this grouping for change sets it has just
// written; TestRevertResultCarriesItsCounts holds the two in step.
countRows, err := d.sql.QueryContext(ctx, countRows, err := d.sql.QueryContext(ctx,
`SELECT change_set_id, entity_type, op, COUNT(*) `SELECT change_set_id, entity_type, op, COUNT(*)
FROM revisions FROM revisions