feat(audiobooks): migration 153 + BookmarkStore.CountByUser extension
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ba6eaf0c05
commit
502f0d10d6
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_smart_collections;
|
||||
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user