refactor(collections): retire the legacy rules surface — tree-only storage

The pre-tree rules machinery is deleted: no row ever carried a rules
array or a non-default match mode (verified in the dev DB), the web
never read the envelope keys, and the Android app consumes none of it.
Dead code gets no documentation.

Removed:
- PUT /api/collections/:id/match-mode — endpoint, route, and the
  UpdateCollectionMatchMode query. The tree's root mode owns any|all.
- auto_assign_rules / rule_match_mode from every envelope
  (create/list/detail/update), from the create/update request shapes,
  and from the schema: both columns DROPPED (schema.sql carries
  idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc
  regenerated).
- The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule +
  validateRule + UpdateCollectionRulesJSON), ParseRules /
  EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR
  preload branch), the legacy branch of EvaluateSmartMembership, and
  SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence;
  create-path isSmart = collection_type=smart OR a filter_tree.

Kept (live consumers): POST /api/collections/preview still evaluates
rules payloads in memory (the custom-section builder's preview), and
include_auto stays on the list (the Android app sends it).

The custom-section builder's SAVE was already broken — its
stringified auto_assign_rules payload failed request binding (probed
live: 400 invalid request). Create now rejects that payload loudly
with a 400 naming the retirement, instead of silently creating a
hollow static section; custom-section-builder.md carries the flag.
The builder's tree migration remains the flagged follow-up.

Test-side (asymmetry rule — deleted behavior, deleted tests):
TestPodcastSmartCollections rewritten onto trees (mode lives in the
tree root), match-mode subtests and tree_wins_over_legacy_rules
deleted, TestRulesToFilterTree deleted, contract-pin envelope keys
retracted per the contract_pins 2026-10-31 retirement entry. Mirrors
hand-synced: openapi.yaml, bruno create/update, docs/api/*.md,
docs/schema.go, api.d.ts. Full container suite green (155s).
This commit is contained in:
John O'Keefe committed 2026-10-31 19:06:48 -04:00
1 parent ab98015190
commit a86bdac87e
27 files changed
+239 -1093

No files matched your search

+24 -61
View File
@@ -772,7 +772,6 @@ func TestPodcastSmartCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
adminToken := setup.Token
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000)
@@ -816,13 +815,18 @@ func TestPodcastSmartCollections(t *testing.T) {
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
// Smart collection with the briefing's AND shape, created through
// the real API.
// the real API. The tree carries the mode ('all' at the root) — the
// match-mode endpoint retired 2026-10-31 with the rules storage.
briefing, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID}},
{"field": "played", "op": "is_false"},
{"field": "published_date", "op": "within_days", "values": []string{"7"}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","auto_assign_rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","filter_tree":`+string(briefing)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
@@ -830,17 +834,15 @@ func TestPodcastSmartCollections(t *testing.T) {
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// ALL mode (AND): the daily briefing — feed ∈ {A} ∧ unplayed ∧ <7d.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
t.Run("all_mode_is_the_briefing", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
Books []struct{ Title string `json:"title"` } `json:"books"`
IsSmart bool `json:"is_smart"`
MatchAll string `json:"rule_match_mode"`
Books []struct {
Title string `json:"title"`
} `json:"books"`
IsSmart bool `json:"is_smart"`
Tree map[string]interface{} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal(body, &out))
require.True(t, out.IsSmart)
@@ -848,34 +850,11 @@ func TestPodcastSmartCollections(t *testing.T) {
for _, b := range out.Books {
titles[b.Title] = true
}
assert.Equal(t, "all", out.MatchAll, "the match mode round-trips")
assert.Equal(t, "all", out.Tree["mode"], "the tree's root mode IS the match mode now")
assert.True(t, titles["SmartColl A New"], "feed-A + unplayed + <7d matches")
assert.Len(t, out.Books, 1, "exactly the briefing set — aOld fails <7d, aPlayed fails unplayed, bEp is RLS-dropped")
})
t.Run("any_mode_widens_to_or", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`)
require.Equal(t, http.StatusOK, code)
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "SmartColl A Old", "OR: aOld matches unplayed even though it's 30d old")
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "OR: aPlayed matches via the feed rule alone")
assert.NotContains(t, string(body), "SmartColl B Ep", "unsubscribed feed's episode never reaches evaluation (RLS)")
})
t.Run("match_mode_is_owner_only", func(t *testing.T) {
code, body := podcastAuthed(t, setup, adminToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body))
})
t.Run("match_mode_endpoint_still_serves_legacy", func(t *testing.T) {
// Item 3.4 retired the rules PAGE, not the legacy channel: the
// match-mode endpoint keeps serving rules-array collections.
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`)
assert.Equal(t, http.StatusOK, code, "%s", string(body))
})
// ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ----
// One more briefing match so the chain has a next hop.
@@ -885,11 +864,10 @@ func TestPodcastSmartCollections(t *testing.T) {
date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID)
t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) {
// Back to ALL: today's unplayed feed-A episodes = aNew + aNew2,
// chained in native feed order. Playing the FIRST match (the
// header's Play-as-Sequence target) must chain into the second.
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
require.Equal(t, http.StatusOK, code)
// ALL mode is the stored tree root: today's unplayed feed-A
// episodes = aNew + aNew2, chained in native feed order. Playing
// the FIRST match (the header's Play-as-Sequence target) must
// chain into the second.
// The detail page (smart branch, All Libraries view) renders
// episode cards with the play affordance + the header button.
@@ -933,10 +911,9 @@ func TestPodcastSmartCollections(t *testing.T) {
}
// TestEpisodeCollectionsFilterTree pins item 3.2: smart collections
// WITH A FILTER TREE evaluate through the unified engine; the briefing
// rules ride the qf DSL; the reader chain follows the tree's live
// membership; the tree wins when legacy rules coexist; edits without a
// tree keep the stored one.
// evaluate through the unified engine; the briefing rules ride the qf
// DSL; the reader chain follows the tree's live membership; edits
// without a tree keep the stored one.
func TestEpisodeCollectionsFilterTree(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
@@ -1023,20 +1000,6 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
"an invalid tree must be rejected at write: %s", string(body))
})
t.Run("tree_wins_over_legacy_rules", func(t *testing.T) {
// Update carries BOTH: a rules array that would match everything
// with the marker, and the strict briefing tree. The tree drives.
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","rule_match_mode":"any","auto_assign_rules":[{"field":"title","operator":"contains","value":"TreeColl"}],"filter_tree":`+string(briefing)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
h := string(body)
assert.Contains(t, h, "TreeColl New")
assert.NotContains(t, h, "TreeColl Old",
"the tree's AND governs — legacy rules alone would have matched Old")
})
t.Run("edit_form_without_tree_keeps_it", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","description":"kept the tree"}`)