feat(filter): label endpoint names id values for client-held trees

The SSR embed (65f257f3) covers trees the server renders; one path
still fell through to the flawed fallback — applying a SAVED filter
from a cold bookshelf. That page was rendered before any filter
existed (nothing embedded), and the fallback reused the suggest facet,
whose rows are MEDIA-CONSTRAINED: "feed in [A,B,C] AND title contains
Chapter" labeled only the feeds with a matching episode (the exact
live repro from the original bug report).

- GET /api/filter/labels?field=X&ids=a,b,c resolves names straight
  from the entity tables via the shared ResolveFieldLabels core
  (extracted from FilterTreeLabels): no media join, no tree
  constraint. RLS/visibility ride the request identity — a
  zero-episode subscribed feed resolves its title (pinned; the facet
  path structurally could not), an unsubscribed feed id stays unnamed
  (pinned), non-id fields are 400 (pinned), ids cap at 100.
- Client: resolveIdLabels/resolveLabelsAsync use ONLY the endpoint;
  the quiet suggest-fetch mode is deleted (its only caller was the
  flawed fallback). The saved-filter apply flow resolves through the
  endpoint and rebuilds the chips bar when labels land — chips render
  raw ids only while the round trip is in flight, never stuck.
- 188 web tests green; Go endpoint pins + filter/episode slices green;
  contract_pins documents the endpoint and the fallback removal.
This commit is contained in:
John O'Keefe committed 2026-11-01 13:40:25 -05:00
1 parent a20741ccc9
commit 96a40f7d88
8 files changed
+274 -78

No files matched your search

+74 -44
View File
@@ -29,9 +29,25 @@ import (
// a legal pick, the resolver only names it.
// ════════════════════════════════════════════════════════════════════
// maxResolveIDs bounds one field's resolution query (picks accumulate
// per rule; menus cap far below this).
const maxResolveIDs = 100
// MaxResolveIDs bounds one field's resolution query (picks accumulate
// per rule; menus cap far below this). The label endpoint truncates to
// the same cap.
const MaxResolveIDs = 100
// IsIDValuedField — whether the field's values are entity ids (the
// label endpoint's accepted universe: id-kind + a known source).
func IsIDValuedField(field string) bool {
f, ok := FilterFieldByKey(field)
if !ok {
return false
}
return valueKindFor(f.Kind, field) == VKId && f.Suggest != ""
}
// ParseUUIDValues splits comma-joined picks into uuids, skipping junk.
func ParseUUIDValues(values []string) []uuid.UUID {
return parseUUIDValues(values)
}
// FilterTreeLabels resolves every id-kind rule value in the tree to its
// display name. Best-effort by design: a resolve failure degrades to
@@ -64,7 +80,7 @@ func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID
continue
}
for _, id := range parseUUIDValues(c.Values) {
if len(byField[c.Field]) >= maxResolveIDs {
if len(byField[c.Field]) >= MaxResolveIDs {
return
}
byField[c.Field] = append(byField[c.Field], id)
@@ -74,48 +90,62 @@ func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID
walk(tree.Children)
for field, ids := range byField {
f, _ := FilterFieldByKey(field)
idArgs := make([]uuid.UUID, len(ids))
copy(idArgs, ids)
var sqlStr string
var args []any
switch f.Suggest {
case suggestLibrary:
// The facet's visibility shape, minus the media join: a
// hidden library is invisible even by id (family lens).
sqlStr = `SELECT l.id::text, l.name
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
args = []any{idArgs, userID}
case suggestFeed:
// RLS-governed (subscription + visibility predicate): an
// unsubscribed feed id resolves to nothing.
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
args = []any{idArgs}
case suggestCollection:
// RLS-governed (app_can_read_collection).
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
args = []any{idArgs}
case suggestAddedBy:
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
args = []any{idArgs}
default:
continue
for k, v := range s.ResolveFieldLabels(ctx, userID, field, ids) {
labels[k] = v
}
rows, err := s.raw.Query(ctx, sqlStr, args...)
if err != nil {
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
continue
}
return labels
}
// ResolveFieldLabels names one field's picked ids against its source
// table — the shared core of the SSR embed and the label endpoint.
// Unknown fields resolve to nothing (the caller validates).
func (s *SearchService) ResolveFieldLabels(ctx context.Context, userID pgtype.UUID, field string, ids []uuid.UUID) map[string]string {
labels := map[string]string{}
f, ok := FilterFieldByKey(field)
if !ok {
return labels
}
idArgs := make([]uuid.UUID, len(ids))
copy(idArgs, ids)
var sqlStr string
var args []any
switch f.Suggest {
case suggestLibrary:
// The facet's visibility shape, minus the media join: a
// hidden library is invisible even by id (family lens).
sqlStr = `SELECT l.id::text, l.name
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
args = []any{idArgs, userID}
case suggestFeed:
// RLS-governed (subscription + visibility predicate): an
// unsubscribed feed id resolves to nothing.
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
args = []any{idArgs}
case suggestCollection:
// RLS-governed (app_can_read_collection).
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
args = []any{idArgs}
case suggestAddedBy:
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
args = []any{idArgs}
default:
return labels
}
rows, err := s.raw.Query(ctx, sqlStr, args...)
if err != nil {
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
return labels
}
defer rows.Close()
for rows.Next() {
var v, display string
if err := rows.Scan(&v, &display); err != nil {
break
}
for rows.Next() {
var v, display string
if err := rows.Scan(&v, &display); err != nil {
break
}
labels[v] = display
}
rows.Close()
labels[v] = display
}
return labels
}