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

+5
View File
@@ -243,6 +243,11 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
// ExecuteSearch performs search and returns results with count
// Public wrapper for shared search logic used by both JSON and HTML endpoints
// SearchService exposes the shared search engine for SSR surfaces
// that render a filter tree (the collection editor and bookshelf pages
// resolve id labels through it — 2026-11-01).
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
return h.searchService.ExecuteSearch(ctx, params)
}
+21 -2
View File
@@ -409,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
}
}
// SSR id-label resolution for a URL-carried qf tree: the drawer's
// value cells show NAMES from first paint instead of raw uuids
// (2026-11-01 — same story as the collection editor above).
labelsJSON := "{}"
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
}
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -706,12 +716,14 @@ func registerFrontendRoutes(cfg *Config) {
// with the rules-array storage — the tree is the only form.)
scope := "books"
treeJSON := "{}"
var parsedTree *services.FilterTree
if len(collection.FilterTree) > 0 {
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
scope = t.FilterScope()
if b, merr := json.Marshal(t); merr == nil {
treeJSON = string(b)
}
parsedTree = t
}
}
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
@@ -720,6 +732,13 @@ func registerFrontendRoutes(cfg *Config) {
}
fieldsJSON := string(fieldsPayload)
// SSR id-label resolution: the stored tree arrives as bare ids;
// embed the id→name map so value cells render NAMES at first
// paint (2026-11-01: the client's pick-time cache dies with the
// page, and the quiet suggest fetch can't label ids whose media
// doesn't match the tree's sibling rules — raw uuids on refresh).
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -731,7 +750,7 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope).Render(c.Request().Context(), &buf)
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+26
View File
@@ -297,6 +297,32 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
// detail page and the books-grid partial: smart evaluation, a dashboard
// query section, or manual membership. Callers resolve the scope:
// libUUID/libScope/isAll/scoped per their own resolution rules.
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
// filter tree: walks the tree's id-kind rules, resolves uuids to display
// names through the search engine (RLS/visibility ride the request
// identity), and returns the JSON map embedded into the builder mount.
// Best-effort: any failure yields "{}" and the page renders with the
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
if tree == nil || len(tree.Children) == 0 {
return "{}"
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return "{}"
}
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
if len(labels) == 0 {
return "{}"
}
b, merr := json.Marshal(labels)
if merr != nil {
return "{}"
}
return string(b)
}
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
colService := cfg.CollectionHandler.CollectionService()
isSmart = services.IsCollectionSmart(collection)
+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
}