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

+10 -1
View File
@@ -1347,6 +1347,9 @@ CREATE TABLE IF NOT EXISTS collections (
-- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d).
-- Applied only when rules exist; static collections ignore it.
rule_match_mode TEXT NOT NULL DEFAULT 'any',
-- The unified filter tree (plan item 3.2): when present the qf
-- engine drives membership; see FilterTree. NULL = legacy rules.
filter_tree JSONB,
view_settings JSONB,
show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter',
@@ -1367,12 +1370,18 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- DEFAULT covers existing rows.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any';
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
-- qf tree (same DSL as saved filters, See FilterTree). NULL = no tree
-- (the legacy rules array governs until the collection is rewritten).
-- Legacy rule_match_mode stays authoritative for rules-array
-- collections; a tree's root mode owns its semantics.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
WHERE show_on_dashboard = true;
-- Create user_dashboard_preferences table
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),