feat(audiobooks): ABSBookmarkStore — pgx-backed concrete store

Implements abs.BookmarkStore against abs_bookmarks (migration 148).
COALESCE-to-sentinel-UUID matches the table's unique index for
profile NULL collapsing. Upsert is one round-trip via INSERT ... ON
CONFLICT ... DO UPDATE RETURNING.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 14:11:48 +02:00
co-authored by Claude Opus 4.7
parent a0dcc860b4
commit ddab7be474
+127
View File
@@ -0,0 +1,127 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSBookmarkStore implements abs.BookmarkStore against the
// abs_bookmarks table (migration 148). One row per
// (user, profile, item, time) — uniqueness is enforced by the
// abs_bookmarks_user_profile_item_time_uniq index, with the
// COALESCE-to-sentinel-UUID trick collapsing NULL profile_id into a
// single bucket per user.
type ABSBookmarkStore struct {
Pool *pgxpool.Pool
}
// Compile-time assertion that ABSBookmarkStore satisfies the
// abs.BookmarkStore contract. Catches signature drift at build time.
var _ abs.BookmarkStore = (*ABSBookmarkStore)(nil)
// profileArg returns the value to bind for the profile_id column.
// pgx interprets a (*string)(nil) as SQL NULL, which is exactly what
// the schema wants for primary-profile rows.
func profileArg(profileID string) any {
if profileID == "" {
return nil
}
return profileID
}
// List returns all bookmarks for (user, profile, item) ordered by
// time_seconds ASC. Empty slice (never nil) when none exist.
func (s *ABSBookmarkStore) List(ctx context.Context, userID, profileID, itemID string) ([]abs.Bookmark, 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 id, library_item_id, time_seconds, title, created_at, updated_at
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)
AND library_item_id = $3
ORDER BY time_seconds ASC`,
uid, profileArg(profileID), itemID,
)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.Bookmark, 0)
for rows.Next() {
var b abs.Bookmark
if err := rows.Scan(&b.ID, &b.LibraryItemID, &b.Time, &b.Title, &b.CreatedAt, &b.UpdatedAt); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list scan: %w", err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list rows: %w", err)
}
return out, nil
}
// Upsert inserts a new bookmark or updates the title at the exact
// (user, profile, item, time) tuple. ID is generated on insert and
// preserved on update.
func (s *ABSBookmarkStore) Upsert(ctx context.Context, userID, profileID, itemID string, timeSeconds float64, title string) (abs.Bookmark, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return abs.Bookmark{}, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
id := ulid.Make().String()
var out abs.Bookmark
row := s.Pool.QueryRow(ctx, `
INSERT INTO abs_bookmarks
(id, user_id, profile_id, library_item_id, time_seconds, title)
VALUES ($1, $2, $3::uuid, $4, $5, $6)
ON CONFLICT (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_item_id,
time_seconds
) DO UPDATE
SET title = EXCLUDED.title,
updated_at = now()
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
id, uid, profileArg(profileID), itemID, timeSeconds, title,
)
if err := row.Scan(&out.ID, &out.LibraryItemID, &out.Time, &out.Title, &out.CreatedAt, &out.UpdatedAt); err != nil {
return abs.Bookmark{}, fmt.Errorf("abs_bookmark_store: upsert: %w", err)
}
return out, nil
}
// Delete removes the bookmark at (user, profile, item, time).
// Returns nil when no row matched — DELETE is idempotent per
// the BookmarkStore contract.
func (s *ABSBookmarkStore) Delete(ctx context.Context, userID, profileID, itemID string, timeSeconds float64) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
_, err = s.Pool.Exec(ctx, `
DELETE 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)
AND library_item_id = $3
AND time_seconds = $4`,
uid, profileArg(profileID), itemID, timeSeconds,
)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("abs_bookmark_store: delete: %w", err)
}
return nil
}