package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// repoSelect is the common projection used by every repository read. It joins
// "user" to resolve the owner's username (core.Repo.OwnerName). description is
// nullable in the schema, so it is coalesced to the empty string.
const repoSelect = `
SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id,
COALESCE(u.username, ''), r.path, r.visibility, r.created, r.updated
FROM repository r
JOIN "user" u ON u.id = r.owner_id`
func scanRepo(sc rowScanner) (*core.Repo, error) {
var (
r core.Repo
visibility string
)
if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID,
&r.OwnerName, &r.Path, &visibility, &r.Created, &r.Updated); err != nil {
return nil, err
}
r.Visibility = core.Visibility(visibility)
return &r, nil
}
// CreateRepo inserts a new repository row. It is normally run on a transaction
// (Store.WithTx) so the caller can create the on-disk NBS store in the same
// unit of work and roll both back on failure. r.Name, r.OwnerID, r.Path and
// r.Visibility must be set; ID, created and updated are assigned here and the
// populated repo is returned (with OwnerName carried through from the input,
// which the caller already knows).
//
// Both unique violations that a same-name re-create can raise are mapped to
// ErrNameTaken: uq_repo_owner_id_name and repository_path_key. The path index
// is redundant with (owner_id, name) — Path is always derived from the pair by
// RepoDiskPath — but it is declared inline on the column, so its index has the
// lower OID and Postgres reports *it* first. Matching only the named constraint
// therefore never fired in practice and every duplicate surfaced as a raw
// 23505: a 500 on /internal/repos for each push of an existing companion, a 500
// instead of 409 on the web create form, and a failed adopt on a lost
// remotesapi auto-create race. Any other unique violation is returned unwrapped
// so the caller still sees the true cause.
func (s *Store) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) {
now := time.Now().UTC()
const q = `
INSERT INTO repository (created, updated, name, description, owner_id, path, visibility)
VALUES ($1, $1, $2, $3, $4, $5, $6)
RETURNING id`
var desc any
if r.Description != "" {
desc = r.Description
}
var id int
err := s.q.QueryRowContext(ctx, q,
now, r.Name, desc, r.OwnerID, r.Path, string(r.Visibility)).Scan(&id)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" &&
(pqErr.Constraint == "uq_repo_owner_id_name" ||
pqErr.Constraint == "repository_path_key") {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("insert repository: %w", err)
}
out := *r
out.ID = id
out.Created, out.Updated = now, now
return &out, nil
}
// GetRepoByOwnerAndName resolves a repository by its owner's username and name.
// Returns ErrNotFound if no such repository exists.
func (s *Store) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) {
q := repoSelect + `
WHERE u.username = $1 AND r.name = $2`
repo, err := scanRepo(s.q.QueryRowContext(ctx, q, ownerUsername, name))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get repo %s/%s: %w", ownerUsername, name, err)
}
return repo, nil
}
// GetRepoByID resolves a repository by its primary key. Returns ErrNotFound if
// no such repository exists.
func (s *Store) GetRepoByID(ctx context.Context, id int) (*core.Repo, error) {
q := repoSelect + `
WHERE r.id = $1`
repo, err := scanRepo(s.q.QueryRowContext(ctx, q, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get repo %d: %w", id, err)
}
return repo, nil
}
// ListReposByOwner lists the repositories owned by ownerUsername that viewer is
// allowed to see, newest first. The listing rule (distinct from clone/browse
// authorization) is:
//
// - The owner, and any user holding an ACL entry on a repo, always see it
// regardless of visibility (including PRIVATE and UNLISTED).
// - Everyone else — including anonymous viewers (viewer == nil) — sees only
// PUBLIC repositories. UNLISTED repositories are never listed to non-owners
// without an ACL, and PRIVATE ones are never listed either.
//
// viewer is the browsing principal; pass nil for an anonymous request.
func (s *Store) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) {
viewerID := 0
if viewer != nil {
viewerID = viewer.UserID
}
q := repoSelect + `
WHERE u.username = $1 AND (
r.visibility = 'PUBLIC'
OR r.owner_id = $2
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $2)
)
ORDER BY r.created DESC, r.id DESC`
return s.queryRepos(ctx, q, ownerUsername, viewerID)
}
// ListReposForViewer lists every repository viewer is allowed to see listed,
// across all owners, newest first. It answers the instance-wide question — "what
// may this caller be shown?" — that neither sibling can: ListReposByOwner asks
// the same listing question but only about one owner's repositories, and
// ListReposForDashboard asks a narrower question (what does this user own or
// hold an ACL on) that omits every PUBLIC repository belonging to someone else.
// Consumers that must not silently understate the instance — a cross-owner
// listing tool, the cross-database ready page — need this one.
//
// The listing rule is exactly ListReposByOwner's, minus the owner filter:
//
// - The owner, and any user holding an ACL entry on a repo, see it regardless
// of visibility (including PRIVATE and UNLISTED).
// - Everyone else sees only PUBLIC repositories. UNLISTED ones stay reachable
// by direct address — that is a browse question, not a listing one — but are
// never listed to a stranger, and PRIVATE ones are never listed either.
//
// Anonymous (viewer == nil) is an ordinary caller here, not an error: it gets
// the PUBLIC set. An instance with nothing public yields an empty result and no
// error.
func (s *Store) ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) {
// Anonymous is spelled as user id 0, which no mirrored user row can carry
// (ids come from meta and are positive), so both identity branches below are
// simply false for it and only the PUBLIC branch can match.
viewerID := 0
if viewer != nil {
viewerID = viewer.UserID
}
q := repoSelect + `
WHERE r.visibility = 'PUBLIC'
OR r.owner_id = $1
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1)
ORDER BY r.created DESC, r.id DESC`
return s.queryRepos(ctx, q, viewerID)
}
// ListReposForDashboard lists every repository the given user owns or holds an
// ACL entry on, newest first. Used for the signed-in user's dashboard.
func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {
q := repoSelect + `
WHERE r.owner_id = $1
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1)
ORDER BY r.created DESC, r.id DESC`
return s.queryRepos(ctx, q, userID)
}
func (s *Store) queryRepos(ctx context.Context, q string, args ...any) ([]*core.Repo, error) {
rows, err := s.q.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
defer rows.Close()
var repos []*core.Repo
for rows.Next() {
repo, err := scanRepo(rows)
if err != nil {
return nil, fmt.Errorf("scan repo: %w", err)
}
repos = append(repos, repo)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate repos: %w", err)
}
return repos, nil
}
// UpdateRepo updates the mutable repository fields (description and visibility)
// and bumps updated. Returns ErrNotFound if id does not exist.
func (s *Store) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error {
const q = `
UPDATE repository
SET description = $2, visibility = $3, updated = $4
WHERE id = $1`
var desc any
if description != "" {
desc = description
}
res, err := s.q.ExecContext(ctx, q, id, desc, string(visibility), time.Now().UTC())
if err != nil {
return fmt.Errorf("update repo %d: %w", id, err)
}
return requireOne(res, "update repo")
}
// RenameRepo moves a repository to a new name and on-disk path in one
// statement, and bumps updated. Both columns move together on purpose: path is
// derived from (owner, name) by storage.RepoDiskPath, and a row whose name and
// path disagree would be served from the wrong store.
//
// The caller is responsible for validating name (core.ValidateName) and for
// moving the store on disk; this is the metadata half only. A name already
// taken by the same owner comes back as ErrNameTaken — mapped from both unique
// indexes the way CreateRepo maps them, because a rename to an existing
// database trips exactly the same pair — and a missing id as ErrNotFound.
func (s *Store) RenameRepo(ctx context.Context, id int, name, path string) error {
const q = `
UPDATE repository
SET name = $2, path = $3, updated = $4
WHERE id = $1`
res, err := s.q.ExecContext(ctx, q, id, name, path, time.Now().UTC())
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" &&
(pqErr.Constraint == "uq_repo_owner_id_name" ||
pqErr.Constraint == "repository_path_key") {
return ErrNameTaken
}
return fmt.Errorf("rename repo %d: %w", id, err)
}
return requireOne(res, "rename repo")
}
// DeleteRepo removes a repository row (cascading to its access entries). The
// on-disk store removal is the caller's responsibility. Returns ErrNotFound if
// id does not exist.
func (s *Store) DeleteRepo(ctx context.Context, id int) error {
res, err := s.q.ExecContext(ctx, `DELETE FROM repository WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete repo %d: %w", id, err)
}
return requireOne(res, "delete repo")
}
// requireOne turns a zero-rows-affected result into ErrNotFound so that missing
// targets surface loudly instead of passing silently.
func requireOne(res sql.Result, what string) error {
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%s: rows affected: %w", what, err)
}
if n == 0 {
return ErrNotFound
}
return nil
}