Reshape the dead per-user transcripts table (PK videos.id, user_id, RLS-FORCEd — never read or written by app code) into the shared public caption store ADR-021 specifies: keyed by (provider, provider_video_id), no user_id, NOT RLS-scoped. Migration 015 (reversible). Add ports.TranscriptStore + Store.GetTranscript/SaveTranscript via the raw pool (no withUser): public content, shared across users by construction. SaveTranscript persists only terminal outcomes (captions/none) and refuses SourceRateLimited so a transient 429 can never be stored as a false permanent absence (ADR-014). Flip the isolation proof: transcripts leaves the RLS-scoped set; TestTranscriptsTableIsSharedNotRLS asserts it is the SINGLE non-RLS surface (writable/readable with no user scope, no user_id column, RLS off on it alone, still on every user-owned table) — the proof the public-content classification was applied exactly here and leaked nowhere. appPool made idempotent so two tests can build it. Adjust the 010/011/014 up-down migration tests for the new HEAD. account.go: user deletion no longer strips shared transcripts. Reconcile data-model.md + CLAUDE.md. Wiring the engine to read-stored-first is the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
67 lines
2.7 KiB
Go
67 lines
2.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
|
)
|
|
|
|
// GetTranscript returns the shared, stored transcript for a video keyed by the
|
|
// cross-user dedup key (provider, providerVideoID), and whether one exists
|
|
// (ADR-021). It reads via the raw pool, NOT withUser: the table holds public
|
|
// content with no user_id and no RLS policy, so it is shared across users by
|
|
// construction. A stored SourceNone is a real hit (ok == true, HasText() ==
|
|
// false) — a known caption-less video, so the caller skips without re-fetching.
|
|
func (s *Store) GetTranscript(ctx context.Context, provider, providerVideoID string) (domain.Transcript, bool, error) {
|
|
var source, lang, content string
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT source, COALESCE(language, ''), COALESCE(content, '')
|
|
FROM transcripts WHERE provider = $1 AND provider_video_id = $2`,
|
|
provider, providerVideoID).Scan(&source, &lang, &content)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.Transcript{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return domain.Transcript{}, false, fmt.Errorf("store: get transcript: %w", err)
|
|
}
|
|
return domain.Transcript{
|
|
Source: domain.TranscriptSource(source),
|
|
Language: lang,
|
|
Content: content,
|
|
}, true, nil
|
|
}
|
|
|
|
// SaveTranscript upserts the shared transcript for (provider, providerVideoID).
|
|
// Only terminal outcomes belong here: SourceCaptions (with text) or SourceNone
|
|
// (no captions). A transient SourceRateLimited is rejected so persistence never
|
|
// masks a 429 as a permanent absence — that stays a per-user retry (ADR-014).
|
|
// Last write wins on conflict (a later re-fetch may correct an entry). It writes
|
|
// via the raw pool, NOT withUser — public content, shared, non-RLS (ADR-021).
|
|
func (s *Store) SaveTranscript(ctx context.Context, provider, providerVideoID string, t domain.Transcript) error {
|
|
switch t.Source {
|
|
case domain.SourceCaptions, domain.SourceNone:
|
|
// terminal — persist
|
|
case domain.SourceRateLimited:
|
|
return fmt.Errorf("store: refusing to persist transient rate-limited transcript for %s/%s", provider, providerVideoID)
|
|
default:
|
|
return fmt.Errorf("store: invalid transcript source %q", t.Source)
|
|
}
|
|
_, err := s.pool.Exec(ctx,
|
|
`INSERT INTO transcripts (provider, provider_video_id, source, language, content)
|
|
VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''))
|
|
ON CONFLICT (provider, provider_video_id)
|
|
DO UPDATE SET source = EXCLUDED.source,
|
|
language = EXCLUDED.language,
|
|
content = EXCLUDED.content,
|
|
fetched_at = NOW()`,
|
|
provider, providerVideoID, string(t.Source), t.Language, t.Content)
|
|
if err != nil {
|
|
return fmt.Errorf("store: save transcript: %w", err)
|
|
}
|
|
return nil
|
|
}
|