From 59050c4db62e6f8d3c5fd251791d0f489b4b22bb Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 9 Jun 2026 21:39:40 +0200 Subject: [PATCH] feat(store): NewestUnsummarizedVideoIDs for the onboarding cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns up to limit of a user's newest videos (published_at DESC, NULLS LAST) that have no summary yet. RLS-scoped via withUser — the test proves a second user's newer video never leaks. Drives the connect-time onboarding burst (Feature 1); the caller routes each through the shared rate gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/store/videos.go | 38 ++++++++++++++++++++++++++ internal/adapters/store/videos_test.go | 32 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/internal/adapters/store/videos.go b/internal/adapters/store/videos.go index 8a0e13a..1a6b448 100644 --- a/internal/adapters/store/videos.go +++ b/internal/adapters/store/videos.go @@ -72,3 +72,41 @@ func nullTime(t time.Time) *time.Time { } return &t } + +// NewestUnsummarizedVideoIDs returns up to limit of the user's videos that have +// no summary yet, newest first (published_at DESC, NULLS LAST). It caps the +// connect-time onboarding burst (Feature 1) at a fixed count: the caller marks +// these for summarization through the shared rate gate. RLS-scoped via withUser, +// so it only ever sees the requesting user's rows. limit <= 0 returns nil. +func (s *Store) NewestUnsummarizedVideoIDs(ctx context.Context, userID string, limit int) ([]string, error) { + if limit <= 0 { + return nil, nil + } + var ids []string + if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, + `SELECT v.id + FROM videos v + WHERE v.user_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM summaries su + WHERE su.user_id = v.user_id AND su.video_id = v.id) + ORDER BY v.published_at DESC NULLS LAST, v.seen_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return fmt.Errorf("store: newest unsummarized: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("store: scan newest unsummarized: %w", err) + } + ids = append(ids, id) + } + return rows.Err() + }); err != nil { + return nil, err + } + return ids, nil +} diff --git a/internal/adapters/store/videos_test.go b/internal/adapters/store/videos_test.go index a30d383..c5b5762 100644 --- a/internal/adapters/store/videos_test.go +++ b/internal/adapters/store/videos_test.go @@ -81,3 +81,35 @@ func TestUpsertVideo_PerUserIsolation(t *testing.T) { require.NotEqual(t, idA, idB, "same provider video for two users must be two distinct rows") } + +func TestNewestUnsummarizedVideoIDs(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + mk := func(user, pid string, day int) string { + v := ytVideo(user, pid, pid) + v.PublishedAt = time.Date(2026, 6, day, 12, 0, 0, 0, time.UTC) + id, err := s.UpsertVideo(ctx, v) + require.NoError(t, err) + return id + } + + _ = mk(userA, "a1vid000001", 1) + id2 := mk(userA, "a2vid000002", 2) + id3 := mk(userA, "a3vid000003", 3) + id4 := mk(userA, "a4vid000004", 4) + mk(userB, "b1vid000009", 9) // userB's newest — must never leak via RLS + + // The newest (v4) is summarized, so it's excluded from "unsummarized". + require.NoError(t, s.Deliver(ctx, summary(userA, id4, "done"))) + + // Cap 2, newest-first unsummarized: v3 then v2 (v4 excluded; userB excluded). + got, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 2) + require.NoError(t, err) + require.Equal(t, []string{id3, id2}, got) + + none, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 0) + require.NoError(t, err) + require.Empty(t, none, "limit 0 returns nothing") +}