feat(store): summarization-mode methods + all-videos read

Add the store surface for summarization mode:
- SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent
  user reads as manual (the safe default).
- RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound
  when the video is absent or not owned (RLS hides another user's row).
- RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the
  queued set per pass (mirrors SeenVideoIDs), clear after summarizing.
- ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so
  discovered-but-unsummarized videos appear with empty summary fields. SummaryRow
  gains additive Summarized + SummarizeRequested fields; the summary-only reads
  are untouched.

RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it
touches zero rows (store-level scoping can't prove this — the test pool is a
superuser that bypasses RLS, same caveat documented there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:26:52 +02:00
co-authored by Claude Opus 4.8
parent 748d5eb0bd
commit bdbdce7de1
4 changed files with 404 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// SetAutoSummarize sets the user's auto/manual summarization mode. TRUE =
// automatic (every new video is summarized by `tapir run`); FALSE = manual (the
// user queues videos individually). Per-user, not global (ADR-012). Scoped via
// withUser, so RLS confines the UPDATE to the calling user's own row.
func (s *Store) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
// Ensure the row exists (FK/identity target) before the UPDATE — mirrors
// the Deliver/UpsertVideo paths, so toggling mode works even before the
// first summary lands.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
userID); err != nil {
return fmt.Errorf("store: upsert user: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE users SET auto_summarize = $1 WHERE id = $2`, enabled, userID); err != nil {
return fmt.Errorf("store: set auto summarize: %w", err)
}
return nil
})
}
// GetAutoSummarize reports the user's summarization mode (TRUE = automatic). An
// absent user row reads as FALSE (manual), the safe default. Scoped via withUser.
func (s *Store) GetAutoSummarize(ctx context.Context, userID string) (bool, error) {
var enabled bool
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx,
`SELECT auto_summarize FROM users WHERE id = $1`, userID).Scan(&enabled)
if errors.Is(err, pgx.ErrNoRows) {
enabled = false
return nil
}
return err
}); err != nil {
return false, fmt.Errorf("store: get auto summarize: %w", err)
}
return enabled, nil
}
// RequestSummarize queues a single video for manual summarization by setting its
// summarize_requested flag. The next `tapir run` picks it up and clears the flag.
// Returns ErrNotFound when the video does not exist or is not owned by the user
// (RLS hides another user's row, so the UPDATE matches zero rows). Scoped via
// withUser.
func (s *Store) RequestSummarize(ctx context.Context, userID, videoID string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, videoID)
if err != nil {
return fmt.Errorf("store: request summarize: %w", err)
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// RequestedVideoIDs returns the set of the user's video ids currently flagged for
// manual summarization. The run loop loads it once per pass (mirroring
// SeenVideoIDs) to decide which discovered videos to process in manual mode.
// Scoped by user_id.
func (s *Store) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
requested := make(map[string]bool)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT id FROM videos WHERE user_id = $1 AND summarize_requested = TRUE`, userID)
if err != nil {
return fmt.Errorf("store: requested video ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("store: scan requested id: %w", err)
}
requested[id] = true
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate requested ids: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return requested, nil
}
// ClearSummarizeRequested resets a video's manual queue flag, called by the run
// loop after a queued video is successfully summarized so it is not re-processed
// and the list view drops the "Queued" chip. Scoped via withUser.
func (s *Store) ClearSummarizeRequested(ctx context.Context, userID, videoID string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE videos SET summarize_requested = FALSE WHERE id = $1`, videoID); err != nil {
return fmt.Errorf("store: clear summarize requested: %w", err)
}
return nil
})
}