diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index a79b2cd15..78c8e632f 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -32,6 +32,17 @@ var ( const ( // VersionEventSummaryV1 defines the schema version for v1 of the EventSummary. VersionEventSummaryV1 = "v1" + // MaxHighlights caps the number of detailed items stored in Spanner (The full highlights are stored in GCS). + // Spanner's 10MB limit can easily accommodate this. + // Calculation details: + // A typical highlight contains: + // - Feature info (ID, Name): ~50-80 bytes + // - 2 DocLinks (URL, Title, Slug): ~250 bytes + // - Changes metadata: ~50 bytes + // - JSON structure overhead: ~50 bytes + // Total ≈ 450-500 bytes. + // 10,000 highlights * 500 bytes = 5MB, which is 50% of the 10MB column limit. + MaxHighlights = 10000 ) type SavedSearchState struct { @@ -77,9 +88,97 @@ type SummaryCategories struct { // EventSummary matches the JSON structure stored in the database 'Summary' column. type EventSummary struct { - SchemaVersion string `json:"schemaVersion"` - Text string `json:"text"` - Categories SummaryCategories `json:"categories,omitzero"` + SchemaVersion string `json:"schemaVersion"` + Text string `json:"text"` + Categories SummaryCategories `json:"categories,omitzero"` + Truncated bool `json:"truncated"` + Highlights []SummaryHighlight `json:"highlights"` +} + +// Change represents a value transition from Old to New. +type Change[T any] struct { + From T `json:"from"` + To T `json:"to"` +} + +type FeatureRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type DocLink struct { + URL string `json:"url"` + Title *string `json:"title,omitempty"` + Slug *string `json:"slug,omitempty"` +} + +type BaselineStatus string + +const ( + BaselineStatusLimited BaselineStatus = "limited" + BaselineStatusNewly BaselineStatus = "newly" + BaselineStatusWidely BaselineStatus = "widely" + BaselineStatusUnknown BaselineStatus = "unknown" +) + +type BaselineValue struct { + Status BaselineStatus `json:"status"` + LowDate *time.Time `json:"low_date,omitempty"` + HighDate *time.Time `json:"high_date,omitempty"` +} + +type BrowserStatus string + +const ( + BrowserStatusAvailable BrowserStatus = "available" + BrowserStatusUnavailable BrowserStatus = "unavailable" + BrowserStatusUnknown BrowserStatus = "" +) + +type BrowserValue struct { + Status BrowserStatus `json:"status"` + Version *string `json:"version,omitempty"` +} + +type BrowserName string + +const ( + BrowserChrome BrowserName = "chrome" + BrowserChromeAndroid BrowserName = "chrome_android" + BrowserEdge BrowserName = "edge" + BrowserFirefox BrowserName = "firefox" + BrowserFirefoxAndroid BrowserName = "firefox_android" + BrowserSafari BrowserName = "safari" + BrowserSafariIos BrowserName = "safari_ios" +) + +type SummaryHighlightType string + +const ( + SummaryHighlightTypeAdded SummaryHighlightType = "Added" + SummaryHighlightTypeRemoved SummaryHighlightType = "Removed" + SummaryHighlightTypeChanged SummaryHighlightType = "Changed" + SummaryHighlightTypeMoved SummaryHighlightType = "Moved" + SummaryHighlightTypeSplit SummaryHighlightType = "Split" +) + +type SplitChange struct { + From FeatureRef `json:"from"` + To []FeatureRef `json:"to"` +} + +type SummaryHighlight struct { + Type SummaryHighlightType `json:"type"` + FeatureID string `json:"feature_id"` + FeatureName string `json:"feature_name"` + DocLinks []DocLink `json:"doc_links,omitempty"` + + // Strongly typed change fields to support i18n and avoid interface{} + NameChange *Change[string] `json:"name_change,omitempty"` + BaselineChange *Change[BaselineValue] `json:"baseline_change,omitempty"` + BrowserChanges map[BrowserName]Change[BrowserValue] `json:"browser_changes,omitempty"` + Moved *Change[FeatureRef] `json:"moved,omitempty"` + Split *SplitChange `json:"split,omitempty"` } // SummaryVisitor defines the contract for consuming immutable Event Summaries. @@ -122,59 +221,323 @@ func (g FeatureDiffV1SummaryGenerator) GenerateJSONSummary( d v1.FeatureDiff) ([]byte, error) { var s EventSummary s.SchemaVersion = VersionEventSummaryV1 + + s.Categories, s.Text = g.calculateCategoriesAndText(d) + s.Highlights, s.Truncated = g.generateHighlights(d) + + b, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToSerializeSummary, err) + } + + return b, nil +} + +func (g FeatureDiffV1SummaryGenerator) calculateCategoriesAndText(d v1.FeatureDiff) (SummaryCategories, string) { + var c SummaryCategories var parts []string + // 1. Populate Counts (Categories) if d.QueryChanged { parts = append(parts, "Search criteria updated") - s.Categories.QueryChanged = 1 + c.QueryChanged = 1 } - if len(d.Added) > 0 { parts = append(parts, fmt.Sprintf("%d features added", len(d.Added))) - s.Categories.Added = len(d.Added) + c.Added = len(d.Added) } if len(d.Removed) > 0 { parts = append(parts, fmt.Sprintf("%d features removed", len(d.Removed))) - s.Categories.Removed = len(d.Removed) + c.Removed = len(d.Removed) } if len(d.Moves) > 0 { parts = append(parts, fmt.Sprintf("%d features moved/renamed", len(d.Moves))) - s.Categories.Moved = len(d.Moves) + c.Moved = len(d.Moves) } if len(d.Splits) > 0 { parts = append(parts, fmt.Sprintf("%d features split", len(d.Splits))) - s.Categories.Split = len(d.Splits) + c.Split = len(d.Splits) } - if len(d.Modified) > 0 { parts = append(parts, fmt.Sprintf("%d features updated", len(d.Modified))) - s.Categories.Updated = len(d.Modified) - + c.Updated = len(d.Modified) for _, m := range d.Modified { if len(m.BrowserChanges) > 0 { - s.Categories.UpdatedImpl++ + c.UpdatedImpl++ } if m.NameChange != nil { - s.Categories.UpdatedRename++ + c.UpdatedRename++ } if m.BaselineChange != nil { - s.Categories.UpdatedBaseline++ + c.UpdatedBaseline++ } } } - if len(parts) == 0 { - s.Text = "No changes detected" - } else { - s.Text = strings.Join(parts, ", ") + text := "No changes detected" + if len(parts) > 0 { + text = strings.Join(parts, ", ") } - b, err := json.Marshal(s) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFailedToSerializeSummary, err) + return c, text +} + +func (g FeatureDiffV1SummaryGenerator) generateHighlights(d v1.FeatureDiff) ([]SummaryHighlight, bool) { + var highlights []SummaryHighlight + var truncated bool + + if highlights, truncated = g.processModified(highlights, d.Modified); truncated { + return highlights, true } - return b, nil + if highlights, truncated = g.processAdded(highlights, d.Added); truncated { + return highlights, true + } + + if highlights, truncated = g.processRemoved(highlights, d.Removed); truncated { + return highlights, true + } + + if highlights, truncated = g.processMoves(highlights, d.Moves); truncated { + return highlights, true + } + + if highlights, truncated = g.processSplits(highlights, d.Splits); truncated { + return highlights, true + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processModified(highlights []SummaryHighlight, + modified []v1.FeatureModified) ([]SummaryHighlight, bool) { + for _, m := range modified { + if len(highlights) >= MaxHighlights { + return highlights, true + } + + h := SummaryHighlight{ + Type: SummaryHighlightTypeChanged, + FeatureID: m.ID, + FeatureName: m.Name, + DocLinks: toDocLinks(m.Docs), + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + + if m.BaselineChange != nil { + h.BaselineChange = &Change[BaselineValue]{ + From: toBaselineValue(m.BaselineChange.From), + To: toBaselineValue(m.BaselineChange.To), + } + } + if m.NameChange != nil { + h.NameChange = &Change[string]{ + From: m.NameChange.From, + To: m.NameChange.To, + } + } + + if len(m.BrowserChanges) > 0 { + h.BrowserChanges = make(map[BrowserName]Change[BrowserValue]) + for b, c := range m.BrowserChanges { + if c == nil { + continue + } + var key BrowserName + switch b { + case v1.Chrome: + key = BrowserChrome + case v1.ChromeAndroid: + key = BrowserChromeAndroid + case v1.Edge: + key = BrowserEdge + case v1.Firefox: + key = BrowserFirefox + case v1.FirefoxAndroid: + key = BrowserFirefoxAndroid + case v1.Safari: + key = BrowserSafari + case v1.SafariIos: + key = BrowserSafariIos + default: + continue + } + h.BrowserChanges[key] = Change[BrowserValue]{ + From: toBrowserValue(c.From), + To: toBrowserValue(c.To), + } + } + } + + highlights = append(highlights, h) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processAdded(highlights []SummaryHighlight, + added []v1.FeatureAdded) ([]SummaryHighlight, bool) { + for _, a := range added { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeAdded, + FeatureID: a.ID, + FeatureName: a.Name, + DocLinks: toDocLinks(a.Docs), + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processRemoved(highlights []SummaryHighlight, + removed []v1.FeatureRemoved) ([]SummaryHighlight, bool) { + for _, r := range removed { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeRemoved, + FeatureID: r.ID, + FeatureName: r.Name, + DocLinks: nil, + Moved: nil, + Split: nil, + BaselineChange: nil, + NameChange: nil, + BrowserChanges: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processMoves(highlights []SummaryHighlight, + moves []v1.FeatureMoved) ([]SummaryHighlight, bool) { + for _, m := range moves { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeMoved, + FeatureID: m.ToID, // Use new ID after move + FeatureName: m.ToName, + Moved: &Change[FeatureRef]{ + From: FeatureRef{ID: m.FromID, Name: m.FromName}, + To: FeatureRef{ID: m.ToID, Name: m.ToName}, + }, + BrowserChanges: nil, + BaselineChange: nil, + NameChange: nil, + DocLinks: nil, + Split: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processSplits(highlights []SummaryHighlight, + splits []v1.FeatureSplit) ([]SummaryHighlight, bool) { + for _, split := range splits { + if len(highlights) >= MaxHighlights { + return highlights, true + } + var to []FeatureRef + for _, t := range split.To { + to = append(to, FeatureRef{ID: t.ID, Name: t.Name}) + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeSplit, + FeatureID: split.FromID, + FeatureName: split.FromName, + Split: &SplitChange{ + From: FeatureRef{ID: split.FromID, Name: split.FromName}, + To: to, + }, + Moved: nil, + BrowserChanges: nil, + BaselineChange: nil, + NameChange: nil, + DocLinks: nil, + }) + } + + return highlights, false +} + +func toDocLinks(docs *v1.Docs) []DocLink { + if docs == nil { + return nil + } + links := make([]DocLink, 0, len(docs.MdnDocs)) + for _, d := range docs.MdnDocs { + links = append(links, DocLink{ + URL: d.URL, + Title: d.Title, + Slug: d.Slug, + }) + } + + return links +} + +func toBaselineValue(s v1.BaselineState) BaselineValue { + val := BaselineValue{ + Status: BaselineStatusUnknown, + LowDate: nil, + HighDate: nil, + } + if s.Status.IsSet { + switch s.Status.Value { + case v1.Limited: + val.Status = BaselineStatusLimited + case v1.Newly: + val.Status = BaselineStatusNewly + case v1.Widely: + val.Status = BaselineStatusWidely + } + } + + if s.LowDate.IsSet { + val.LowDate = s.LowDate.Value + } + if s.HighDate.IsSet { + val.HighDate = s.HighDate.Value + } + + return val +} + +func toBrowserValue(s v1.BrowserState) BrowserValue { + val := BrowserValue{ + Status: BrowserStatusUnknown, + Version: nil, + } + if s.Status.IsSet { + switch s.Status.Value { + case v1.Available: + val.Status = BrowserStatusAvailable + case v1.Unavailable: + val.Status = BrowserStatusUnavailable + } + } + if s.Version.IsSet { + val.Version = s.Version.Value + } + + return val } type Reason string diff --git a/lib/workertypes/types_test.go b/lib/workertypes/types_test.go index ea2ad6f99..3263072bb 100644 --- a/lib/workertypes/types_test.go +++ b/lib/workertypes/types_test.go @@ -67,6 +67,8 @@ func TestParseEventSummary(t *testing.T) { UpdatedRename: 0, UpdatedBaseline: 0, }, + Truncated: false, + Highlights: nil, }, wantErr: false, }, @@ -118,6 +120,7 @@ func TestParseEventSummary(t *testing.T) { } func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { + newlyDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) tests := []struct { name string diff v1.FeatureDiff @@ -134,7 +137,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { Moves: nil, Splits: nil, }, - expected: `{"schemaVersion":"v1","text":"No changes detected"}`, + expected: `{"schemaVersion":"v1","text":"No changes detected","truncated":false,"highlights":null}`, expectedError: nil, }, { @@ -143,7 +146,9 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { QueryChanged: true, Added: []v1.FeatureAdded{ {ID: "1", Name: "A", Reason: v1.ReasonNewMatch, Docs: nil}, - {ID: "2", Name: "B", Reason: v1.ReasonNewMatch, Docs: nil}, + {ID: "2", Name: "B", Reason: v1.ReasonNewMatch, Docs: &v1.Docs{ + MdnDocs: []v1.MdnDoc{{URL: "https://mdn.io/B", Title: generic.ValuePtr("B"), Slug: generic.ValuePtr("slug-b")}}, + }}, }, Removed: []v1.FeatureRemoved{ {ID: "3", Name: "C", Reason: v1.ReasonUnmatched}, @@ -167,7 +172,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, To: v1.BaselineState{ Status: generic.SetOpt(v1.Newly), - LowDate: generic.UnsetOpt[*time.Time](), + LowDate: generic.SetOpt(&newlyDate), HighDate: generic.UnsetOpt[*time.Time](), }, }, @@ -188,7 +193,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, To: v1.BrowserState{ Status: generic.SetOpt(v1.Available), Date: generic.UnsetOpt[*time.Time](), - Version: generic.UnsetOpt[*string](), + Version: generic.SetOpt(generic.ValuePtr("123")), }}, v1.ChromeAndroid: nil, v1.Edge: nil, @@ -213,21 +218,116 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, expected: `{ -"schemaVersion":"v1", -"text":"Search criteria updated, 2 features added, 1 features removed, ` + + "schemaVersion": "v1", + "text": "Search criteria updated, 2 features added, 1 features removed, ` + `1 features moved/renamed, 1 features split, 3 features updated", -"categories": - { - "query_changed":1, - "added":2, - "removed":1, - "moved":1, - "split":1, - "updated":3, - "updated_impl":1, - "updated_rename":1, - "updated_baseline":1 - } + "categories": { + "query_changed": 1, + "added": 2, + "removed": 1, + "moved": 1, + "split": 1, + "updated": 3, + "updated_impl": 1, + "updated_rename": 1, + "updated_baseline": 1 + }, + "truncated": false, + "highlights": [ + { + "type": "Changed", + "feature_id": "8", + "feature_name": "H", + "baseline_change": { + "from": { + "status": "limited" + }, + "to": { + "status": "newly", + "low_date": "2025-01-01T00:00:00Z" + } + } + }, + { + "type": "Changed", + "feature_id": "9", + "feature_name": "I", + "browser_changes": { + "chrome": { + "from": { + "status": "unavailable" + }, + "to": { + "status": "available", + "version": "123" + } + } + } + }, + { + "type": "Changed", + "feature_id": "10", + "feature_name": "J", + "name_change": { + "from": "Old", + "to": "New" + } + }, + { + "type": "Added", + "feature_id": "1", + "feature_name": "A" + }, + { + "type": "Added", + "feature_id": "2", + "feature_name": "B", + "doc_links": [ + { + "url": "https://mdn.io/B", + "title": "B", + "slug": "slug-b" + } + ] + }, + { + "type": "Removed", + "feature_id": "3", + "feature_name": "C" + }, + { + "type": "Moved", + "feature_id": "5", + "feature_name": "E", + "moved": { + "from": { + "id": "4", + "name": "D" + }, + "to": { + "id": "5", + "name": "E" + } + } + }, + { + "type": "Split", + "feature_id": "6", + "feature_name": "F", + "split": { + "from": { + "id": "6", + "name": "F" + }, + "to": [ + { + "id": "7", + "name": "G" + } + ] + } + } + ] }`, expectedError: nil, },