fix(filter-builder): id labels resolve at render — refresh keeps names

Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
This commit is contained in:
John O'Keefe committed 2026-11-01 10:44:48 -05:00
1 parent c448fa438f
commit 65f257f377
12 files changed
+533 -80

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package services
import (
"context"
"log"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// ════════════════════════════════════════════════════════════════════
// SSR id-label resolution (2026-11-01): pages that render a filter tree
// (the collection editor's stored tree, the bookshelf's URL qf) embed
// the id→name map so value cells show NAMES at first paint.
//
// Why not reuse the suggest facet: its rows are media-constrained
// (INNER JOIN media_items + the other rules as a constraint), so an id
// whose media doesn't match the tree's sibling rules returns NO row and
// its label is lost — live repro: feed-in-3 + title-contains-Chapter
// labeled one feed of three. A label is a property of the ENTITY row,
// not of what currently matches: resolution reads the source tables
// directly, no media join, no tree constraint.
//
// Scoping rides the request identity through s.raw (the ScopedDB —
// every statement declares app.user_id): podcast_feeds and collections
// are RLS-governed (an invisible feed/collection resolves to nothing),
// libraries mirror the facet's explicit visibility join, and users
// (added_by) are plain lookups — the viewer already holds the id from
// 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
// FilterTreeLabels resolves every id-kind rule value in the tree to its
// display name. Best-effort by design: a resolve failure degrades to
// fewer labels (cells fall back to raw ids — the pre-fix behavior),
// never a failed page render. Returns uuid → display.
func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID, tree *FilterTree) map[string]string {
labels := map[string]string{}
if tree == nil {
return labels
}
// Collect ids per field, split comma-joined picks (the builder
// stores one comma-joined value per multi-pick), cap the query.
byField := map[string][]uuid.UUID{}
var walk func(children []FilterChild)
walk = func(children []FilterChild) {
for _, c := range children {
if len(c.Children) > 0 {
walk(c.Children)
continue
}
if c.Field == "" || len(c.Values) == 0 {
continue
}
f, ok := FilterFieldByKey(c.Field)
if !ok || f.Suggest == "" {
continue
}
if valueKindFor(f.Kind, c.Field) != VKId {
continue
}
for _, id := range parseUUIDValues(c.Values) {
if len(byField[c.Field]) >= maxResolveIDs {
return
}
byField[c.Field] = append(byField[c.Field], id)
}
}
}
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
}
rows, err := s.raw.Query(ctx, sqlStr, args...)
if err != nil {
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
continue
}
for rows.Next() {
var v, display string
if err := rows.Scan(&v, &display); err != nil {
break
}
labels[v] = display
}
rows.Close()
}
return labels
}