diff --git a/appview/models/label.go b/appview/models/label.go --- a/appview/models/label.go +++ b/appview/models/label.go @@ -1,6 +1,7 @@ package models import ( + "cmp" "context" "crypto/sha1" "encoding/hex" @@ -551,6 +552,10 @@ return nil } + if op.Subject != "" && len(def.Scope) > 0 && !slices.Contains(def.Scope, op.Subject.Collection().String()) { + return nil + } + state.names[op.OperandKey] = def.Name switch op.Operation { @@ -601,10 +606,24 @@ return nil } +func labelOpRank(op LabelOperation) int { + if op == LabelOperationDel { + return 0 + } + return 1 +} + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { // sort label ops in sort order first slices.SortFunc(ops, func(a, b LabelOp) int { - return a.SortAt().Compare(b.SortAt()) + return cmp.Or( + a.SortAt().Compare(b.SortAt()), + cmp.Compare(a.Did, b.Did), + cmp.Compare(a.Rkey, b.Rkey), + cmp.Compare(labelOpRank(a.Operation), labelOpRank(b.Operation)), + cmp.Compare(a.OperandKey, b.OperandKey), + cmp.Compare(a.OperandValue, b.OperandValue), + ) }) // apply ops in sequence diff --git a/appview/models/label_test.go b/appview/models/label_test.go new file mode 100644 --- /dev/null +++ b/appview/models/label_test.go @@ -0,0 +1,50 @@ +package models + +import ( + "slices" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestApplyLabelOps_FutureDatedOrderIndependent(t *testing.T) { + def := &LabelDefinition{ + Did: "did:plc:boltless", Rkey: "st", Name: "status", + ValueType: ValueType{Type: ConcreteTypeString}, + Scope: []string{"sh.tangled.repo.issue"}, + Multiple: false, + } + key := def.AtUri().String() + ctx := &LabelApplicationCtx{Defs: map[string]*LabelDefinition{key: def}} + subject := syntax.ATURI("at://did:plc:boltless/sh.tangled.repo.issue/issue1") + + mk := func(rkey, val string, performed time.Time) LabelOp { + return LabelOp{ + Did: "did:plc:boltless", Rkey: rkey, Subject: subject, + Operation: LabelOperationAdd, OperandKey: key, OperandValue: val, + PerformedAt: performed, + } + } + + earlier := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) + later := time.Date(2200, 1, 1, 0, 0, 0, 0, time.UTC) + + fold := func(ops []LabelOp) []string { + st := NewLabelState() + ctx.ApplyLabelOps(st, ops) + out := st.LabelNameValues() + slices.Sort(out) + return out + } + + forward := fold([]LabelOp{mk("aaa", "open", earlier), mk("bbb", "closed", later)}) + reverse := fold([]LabelOp{mk("bbb", "closed", later), mk("aaa", "open", earlier)}) + + if !slices.Equal(forward, reverse) { + t.Fatalf("fold must be order-independent for future-dated ops: forward=%v reverse=%v", forward, reverse) + } + if !slices.Equal(forward, []string{"status:closed"}) { + t.Fatalf("the later createdAt must win regardless of ingest order, got %v", forward) + } +}