feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)

collections gains filter_tree JSONB (idempotent in-place; same qf DSL
as saved filters — the scope key included). IsCollectionSmart is the
ONE detection (tree OR legacy rules), and EvaluateSmartMembership is
the ONE evaluator: tree-bearing collections run the composed engine
via ComposedLiveMedia (full media rows, so the collection surfaces
keep their episode-card stamping, feed positions for the replay
chain, and the family lens rides the mandatory head), while legacy
rule arrays keep per-item FieldMatches until rewritten — one call
surface, two storage epochs. All smart surfaces (detail JSON, counts,
detail page, reader chain) dispatch through it; the search service is
injected into the collection subsystem (variadic — main.go builds one
shared instance; the test harness mirrors it).

Write path: CreateCollection/UpdateCollection accept the tree,
validated against the engine grammar at the HANDLER (an invalid tree
is a 400, never a stored render-time 500) and exposed in the envelope
(pin widened in TestContractPin_CollectionsJSON + changelog). Update
semantics: an edit form that doesn't send a tree keeps the stored one.

RulesToFilterTree converts legacy rules + match mode at upgrade time:
episode rules map mechanically (feed→in-list, played/downloaded→
booleans, published_within→published_date+within_days), tree root
mode absorbs rule_match_mode, episode rules force the episodes scope,
and legacy starts_with/ends_with fall back to contains (documented).

Read-compat by design: nothing is migrated at boot — legacy arrays
evaluate exactly as before until the collection is rewritten.

Pinned by TestRulesToFilterTree (unit conversion) +
TestEpisodeCollectionsFilterTree (a tree collection evaluates through
the engine, the chain follows the tree's live membership, invalid
trees 400 at write, the tree wins when legacy rules coexist, and an
edit without a tree keeps it).
This commit is contained in:
John O'Keefe committed 2026-10-30 19:02:49 -04:00
1 parent 56871b65ed
commit 6b2125a45a
17 files changed
+524 -55

No files matched your search

+161 -8
View File
@@ -78,17 +78,28 @@ type RuleEvaluation struct {
// CollectionService handles collection management
type CollectionService struct {
db *database.Queries
// search powers the unified-engine path (item 3.2): tree-driven
// smart collections evaluate via ComposedLiveMedia. Optional —
// nil disables tree evaluation (legacy-only services, which keeps
// every existing constructor call site valid).
search *SearchService
}
// NewCollectionService creates a new collection service
func NewCollectionService(db *database.Queries) *CollectionService {
return &CollectionService{
db: db,
// NewCollectionService creates a new collection service. search is
// optional (variadic for constructor compatibility) — required for
// TREE-driven smart collections (item 3.2), ignored otherwise.
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
svc := &CollectionService{db: db}
if len(search) > 0 {
svc.search = search[0]
}
return svc
}
// CreateCollection creates a new collection
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
// CreateCollection creates a new collection. filterTree (item 3.2)
// replaces autoAssignRules as the smart engine's source when present —
// both may coexist on the row (tree wins at evaluation).
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
// Convert rules to JSONB ([]byte)
var rulesJSON []byte
if len(autoAssignRules) > 0 {
@@ -117,6 +128,7 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
Icon: pgtype.Text{String: icon, Valid: icon != ""},
AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON,
FilterTree: filterTree,
})
if err != nil {
@@ -155,14 +167,18 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
// UpdateCollection updates a collection
// UpdateCollection rewrites the collection row. ruleMatchMode empty or
// invalid = keep the stored value (the edit form only sends the toggles
// it renders — plan item 2.4).
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string) (database.Collections, error) {
// it renders — plan item 2.4). filterTree semantics: nil = leave the
// stored tree untouched.
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (database.Collections, error) {
// A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive.
existingMode := RuleMatchAny
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
existingMode = existing.RuleMatchMode
if filterTree == nil {
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
}
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
@@ -207,6 +223,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON,
RuleMatchMode: ruleMatchMode,
FilterTree: filterTree,
})
if err != nil {
@@ -461,6 +478,142 @@ type SmartEvalOptions struct {
ViewerUserID pgtype.UUID
}
// IsCollectionSmart is the ONE smart detection: a collection is smart
// when it carries the unified filter tree (item 3.2) or a legacy rules
// array (item 2's shape). Every surface routes through this — rules
// presence alone misses tree collections.
func IsCollectionSmart(coll database.Collections) bool {
if len(coll.FilterTree) > 0 {
return true
}
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil {
return false
}
return len(rules) > 0
}
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
// tree-bearing collections evaluate through the unified filter engine;
// legacy rule arrays keep per-item evaluation until rewritten. One
// call site surface, two storage epochs.
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
if len(coll.FilterTree) > 0 {
return s.evaluateTreeMembership(ctx, coll, opts)
}
return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts)
}
// RulesToFilterTree converts a legacy rules array + its match mode
// into a qf tree so a collection upgrades to the unified engine at its
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
// upgrade at write. Episode rules map mechanically (feed→in-list,
// played/downloaded→booleans, published_within→published_date
// within_days); legacy operators without an engine equivalent
// (starts_with/ends_with) fall back to contains — documented, and the
// operator labels stay in the rules docs.
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
children := make([]FilterChild, 0, len(rules))
scope := ""
for _, r := range rules {
child, err := ruleToFilterChild(r)
if err != nil {
return nil, err
}
if episodeOnlyFields[r.Field] {
scope = ScopeEpisodes
}
children = append(children, child)
}
if len(children) == 0 {
return nil, fmt.Errorf("no rules to convert")
}
mode := RuleMatchAny
if matchMode == RuleMatchAll {
mode = RuleMatchAll
}
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
}
func ruleToFilterChild(r Rule) (FilterChild, error) {
child := FilterChild{Field: r.Field}
switch r.Field {
case "feed":
// The rules format stores comma-separated feed IDs in one value.
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
return child, nil
case "downloaded":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "played":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "published_within":
child.Field = "published_date"
child.Op = OpWithinDays
child.Values = []string{r.Value}
return child, nil
}
// Registry fields: legacy operators → engine operators.
switch r.Operator {
case "equals":
child.Op = OpIs
case "not_equals":
child.Op = OpIsNot
case "contains":
child.Op = OpContains
case "not_contains":
child.Op = OpNotContains
case "greater_than":
child.Op = OpAtLeast
case "less_than":
child.Op = OpAtMost
case "in":
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
case "starts_with", "ends_with":
// No engine equivalent — contains is the honest nearest.
child.Op = OpContains
default:
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
}
child.Values = []string{r.Value}
return child, nil
}
func isTrueFalse(b bool) string {
if b {
return OpIsTrue
}
return OpIsFalse
}
func splitRuleValues(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
tree, err := ParseFilterTree(string(coll.FilterTree))
if err != nil {
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
}
if s.search == nil {
return nil, errors.New("tree collections require the search service (constructor wiring)")
}
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
}
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
// every visible live book matched by at least one enabled rule is in.
// No stored links are consulted — "truly dynamic," always current.