From 502f0d10d6e9f8f44b6782b6ea8a121d06604a97 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 20:22:04 +0200 Subject: [PATCH] feat(audiobooks): migration 153 + BookmarkStore.CountByUser extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 153 backs the upcoming smart-collections surface. BookmarkStore.CountByUser returns per-item counts in one SQL pass โ€” used by the smart-collection items evaluator to hydrate the bookmark_count personalized rule without N+1 queries. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/bookmarks.go | 5 +++ .../audiobooks/abs/bookmarks_handler_test.go | 25 +++++++++++++ internal/audiobooks/abs_bookmark_store.go | 36 +++++++++++++++++++ migrations/153_abs_smart_collections.down.sql | 2 ++ migrations/153_abs_smart_collections.up.sql | 25 +++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 migrations/153_abs_smart_collections.down.sql create mode 100644 migrations/153_abs_smart_collections.up.sql diff --git a/internal/audiobooks/abs/bookmarks.go b/internal/audiobooks/abs/bookmarks.go index b384780b..4f3d7dc0 100644 --- a/internal/audiobooks/abs/bookmarks.go +++ b/internal/audiobooks/abs/bookmarks.go @@ -20,6 +20,11 @@ type BookmarkStore interface { // Returns nil when no row matched โ€” DELETE is idempotent (a UX // convenience, not a 404 surface). See spec ยง6. Delete(ctx context.Context, userID, profileID, itemID string, timeSeconds float64) error + // CountByUser returns a map of library_item_id -> bookmark count + // for the given (user, profile). Empty map (never nil) when none. + // Used by the smart-collection items evaluator to hydrate the + // `bookmark_count` personalized rule in one SQL pass. + CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error) } // Bookmark is the in-memory representation of an abs_bookmarks row as diff --git a/internal/audiobooks/abs/bookmarks_handler_test.go b/internal/audiobooks/abs/bookmarks_handler_test.go index 2e5fec11..f18fbe74 100644 --- a/internal/audiobooks/abs/bookmarks_handler_test.go +++ b/internal/audiobooks/abs/bookmarks_handler_test.go @@ -94,6 +94,31 @@ func (m *memBookmarkStore) Delete(_ context.Context, userID, profileID, itemID s return nil } +func (m *memBookmarkStore) CountByUser(_ context.Context, userID, profileID string) (map[string]int, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := map[string]int{} + prefix := userID + "|" + profileID + "|" + for k := range m.rows { + if strings.HasPrefix(k, prefix) { + rest := k[len(prefix):] + sep := -1 + for i, c := range rest { + if c == '|' { + sep = i + break + } + } + if sep < 0 { + continue + } + itemID := rest[:sep] + out[itemID]++ + } + } + return out, nil +} + func formatSeq(n int) string { b, _ := json.Marshal(n) return string(b) diff --git a/internal/audiobooks/abs_bookmark_store.go b/internal/audiobooks/abs_bookmark_store.go index cbb53a5c..a4b2c8a2 100644 --- a/internal/audiobooks/abs_bookmark_store.go +++ b/internal/audiobooks/abs_bookmark_store.go @@ -122,3 +122,39 @@ func (s *ABSBookmarkStore) Delete(ctx context.Context, userID, profileID, itemID } return nil } + +// CountByUser returns a map of library_item_id -> bookmark count for +// the given (user, profile). One SQL query; used by the +// smart-collection items evaluator for batch hydration. +func (s *ABSBookmarkStore) CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error) { + uid, err := strconv.Atoi(userID) + if err != nil { + return nil, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err) + } + rows, err := s.Pool.Query(ctx, ` + SELECT library_item_id, COUNT(*) + FROM abs_bookmarks + WHERE user_id = $1 + AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid) + = COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + GROUP BY library_item_id`, + uid, profileArg(profileID), + ) + if err != nil { + return nil, fmt.Errorf("abs_bookmark_store: count-by-user: %w", err) + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var itemID string + var count int + if err := rows.Scan(&itemID, &count); err != nil { + return nil, fmt.Errorf("abs_bookmark_store: count-by-user scan: %w", err) + } + out[itemID] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("abs_bookmark_store: count-by-user rows: %w", err) + } + return out, nil +} diff --git a/migrations/153_abs_smart_collections.down.sql b/migrations/153_abs_smart_collections.down.sql new file mode 100644 index 00000000..c8232e09 --- /dev/null +++ b/migrations/153_abs_smart_collections.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx; +DROP TABLE IF EXISTS public.abs_smart_collections; diff --git a/migrations/153_abs_smart_collections.up.sql b/migrations/153_abs_smart_collections.up.sql new file mode 100644 index 00000000..520ffb07 --- /dev/null +++ b/migrations/153_abs_smart_collections.up.sql @@ -0,0 +1,25 @@ +-- Smart Collections โ€” rule-based dynamic groupings of audiobooks. +-- The query_def JSONB column stores the DSL tree (see +-- internal/audiobooks/smartcoll/query.go). Profile-scoped per the +-- established convention; is_public allows cross-user reads with +-- personalization stripped at eval time. + +CREATE TABLE IF NOT EXISTS public.abs_smart_collections ( + id text PRIMARY KEY, + user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + profile_id uuid, + name text NOT NULL, + description text NOT NULL DEFAULT '', + color text NOT NULL DEFAULT '', + is_public boolean NOT NULL DEFAULT false, + is_pinned boolean NOT NULL DEFAULT false, + query_def jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx + ON public.abs_smart_collections ( + user_id, + COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid) + );