Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
242 lines
8.3 KiB
Go
242 lines
8.3 KiB
Go
// Package store is the Postgres-backed implementation of ports.Sink: the user's
|
|
// own durable store of summaries. It is the primary sink (ADR-003) and the source
|
|
// of the engine's durable, cross-restart dedup (the in-engine map is process-
|
|
// lifetime only). Connection is pgx/v5 + pgxpool with the DSN from the caller;
|
|
// schema is applied via golang-migrate from embedded migrations.
|
|
//
|
|
// Per-user isolation (docs/data-model.md) is a Stage-0 promise: every row carries
|
|
// user_id and every read is scoped by it, even though the demo has one user.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/golang-migrate/migrate/v4"
|
|
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// Store persists summaries to Postgres and answers durable dedup queries.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// New connects a pool to dsn, applies all pending migrations, and verifies the
|
|
// connection. The caller owns the lifetime: call Close when done.
|
|
func New(ctx context.Context, dsn string) (*Store, error) {
|
|
if dsn == "" {
|
|
return nil, errors.New("store: empty DSN")
|
|
}
|
|
if err := Migrate(dsn); err != nil {
|
|
return nil, err
|
|
}
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: connect pool: %w", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("store: ping: %w", err)
|
|
}
|
|
return &Store{pool: pool}, nil
|
|
}
|
|
|
|
// Migrate applies all pending up-migrations against dsn. It opens its own
|
|
// short-lived connection (golang-migrate uses database/sql) and closes it before
|
|
// returning, so it can run before the pool is created or be invoked standalone.
|
|
func Migrate(dsn string) error {
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("store: open migrate db: %w", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
drv, err := migratepgx.WithInstance(db, &migratepgx.Config{})
|
|
if err != nil {
|
|
return fmt.Errorf("store: migrate driver: %w", err)
|
|
}
|
|
src, err := iofs.New(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("store: migrate source: %w", err)
|
|
}
|
|
m, err := migrate.NewWithInstance("iofs", src, "pgx", drv)
|
|
if err != nil {
|
|
return fmt.Errorf("store: migrator: %w", err)
|
|
}
|
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
|
return fmt.Errorf("store: migrate up: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close releases the connection pool.
|
|
func (s *Store) Close() {
|
|
s.pool.Close()
|
|
}
|
|
|
|
// Name identifies this sink in delivery records.
|
|
func (s *Store) Name() string { return "store" }
|
|
|
|
// withUser is the single choke point through which EVERY DB access in this
|
|
// package flows, so per-user isolation is structural — not a per-query opt-in
|
|
// someone can forget. It:
|
|
//
|
|
// - BEGINs a transaction,
|
|
// - sets the per-request GUC tapir.current_user_id via
|
|
// set_config('tapir.current_user_id', $1, true). The set_config form is used
|
|
// instead of `SET LOCAL` because it is parameterizable (SET cannot bind a
|
|
// value through the driver); the third arg true = local = transaction-scoped,
|
|
// so it auto-resets on commit/rollback and a pooled connection never leaks one
|
|
// request's user into the next,
|
|
// - runs fn against that transaction,
|
|
// - COMMITs (or ROLLBACKs on error).
|
|
//
|
|
// The migration-003 RLS policies key off this GUC: a row is visible/writable only
|
|
// when its owner = current_setting('tapir.current_user_id'). RLS enforces only
|
|
// when the app connects as a non-superuser, non-BYPASSRLS role (in production the
|
|
// table owner tapir, made subject via FORCE ROW LEVEL SECURITY). A superuser DSN
|
|
// bypasses RLS regardless — see rls_test.go, which connects as a dedicated
|
|
// non-superuser role to prove the enforcement is real.
|
|
func (s *Store) withUser(ctx context.Context, userID string, fn func(pgx.Tx) error) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("store: begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
|
|
|
if _, err := tx.Exec(ctx,
|
|
`SELECT set_config('tapir.current_user_id', $1, true)`, userID); err != nil {
|
|
return fmt.Errorf("store: scope user: %w", err)
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("store: commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Deliver upserts the summary idempotently on (user_id, video_id) and records the
|
|
// store delivery. Re-delivering the same summary updates in place — it never
|
|
// errors or duplicates. The whole write is one transaction so a summary and its
|
|
// delivery row stay consistent.
|
|
func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
|
|
highlights, err := marshalList(sum.Highlights)
|
|
if err != nil {
|
|
return fmt.Errorf("store: marshal highlights: %w", err)
|
|
}
|
|
takeaways, err := marshalList(sum.Takeaways)
|
|
if err != nil {
|
|
return fmt.Errorf("store: marshal takeaways: %w", err)
|
|
}
|
|
|
|
return s.withUser(ctx, sum.UserID, func(tx pgx.Tx) error {
|
|
// Ensure the owning user exists (FK target). The store sink receives only
|
|
// a Summary, so a minimal user row is enough at Stage 0.
|
|
if _, err := tx.Exec(ctx,
|
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
|
sum.UserID); err != nil {
|
|
return fmt.Errorf("store: upsert user: %w", err)
|
|
}
|
|
|
|
var summaryID string
|
|
if err := tx.QueryRow(ctx,
|
|
`INSERT INTO summaries
|
|
(user_id, video_id, summary, highlights, takeaways, ai_provider, ai_model, fallback_used)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (user_id, video_id) DO UPDATE SET
|
|
summary = EXCLUDED.summary,
|
|
highlights = EXCLUDED.highlights,
|
|
takeaways = EXCLUDED.takeaways,
|
|
ai_provider = EXCLUDED.ai_provider,
|
|
ai_model = EXCLUDED.ai_model,
|
|
fallback_used = EXCLUDED.fallback_used
|
|
RETURNING id`,
|
|
sum.UserID, sum.VideoID, sum.Summary, highlights, takeaways,
|
|
sum.AIProvider, sum.AIModel, sum.FallbackUsed,
|
|
).Scan(&summaryID); err != nil {
|
|
return fmt.Errorf("store: upsert summary: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx,
|
|
`INSERT INTO sink_deliveries (summary_id, sink, status)
|
|
VALUES ($1, 'store', 'delivered')
|
|
ON CONFLICT (summary_id, sink) DO UPDATE SET
|
|
status = 'delivered',
|
|
detail = NULL,
|
|
updated_at = NOW()`,
|
|
summaryID); err != nil {
|
|
return fmt.Errorf("store: record delivery: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// HasSummary reports whether a summary already exists for (userID, videoID).
|
|
// This is the per-video durable dedup check.
|
|
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
|
|
var exists bool
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
return tx.QueryRow(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
|
|
userID, videoID).Scan(&exists)
|
|
}); err != nil {
|
|
return false, fmt.Errorf("store: has summary: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// SeenVideoIDs returns the set of video IDs that already have a summary for the
|
|
// user. The watcher uses it to skip re-summarizing across restarts. Scoped by
|
|
// user_id, so one user never sees another's videos.
|
|
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
|
seen := make(map[string]bool)
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
rows, err := tx.Query(ctx,
|
|
`SELECT video_id FROM summaries WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("store: seen 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 video id: %w", err)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("store: iterate video ids: %w", err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return seen, nil
|
|
}
|
|
|
|
// marshalList renders a string slice as a JSON array, normalising nil to "[]" so
|
|
// the jsonb columns never hold SQL/JSON null.
|
|
func marshalList(xs []string) ([]byte, error) {
|
|
if xs == nil {
|
|
xs = []string{}
|
|
}
|
|
return json.Marshal(xs)
|
|
}
|