package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// Space is one bare git repo: the unit of ownership, ACL and review policy.
// The repo on disk is the real thing; this row exists so spaces can be listed
// and so index bookkeeping has something to hang off. Ref.Owner never carries
// the leading '~'.
type Space struct {
ID int
Ref core.SpaceRef
Created time.Time
}
const spaceSelect = `SELECT id, owner, name, created FROM space`
func scanSpace(sc rowScanner) (*Space, error) {
var sp Space
if err := sc.Scan(&sp.ID, &sp.Ref.Owner, &sp.Ref.Name, &sp.Created); err != nil {
return nil, err
}
return &sp, nil
}
// CreateSpace inserts a space row. The name is validated with core before it
// reaches SQL: an owner or space name that is not a safe path segment would
// become a directory under the repos root, so it is rejected at every door
// rather than at whichever one happens to check.
//
// A uq_space_owner_name violation maps to ErrSpaceExists.
func (s *Store) CreateSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) {
if err := core.ValidateOwner(ref.Owner); err != nil {
return nil, err
}
if err := core.ValidateSpaceName(ref.Name); err != nil {
return nil, err
}
const q = `
INSERT INTO space (owner, name, created)
VALUES ($1, $2, $3)
RETURNING id, created`
var sp Space
sp.Ref = ref
err := s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name, time.Now().UTC()).
Scan(&sp.ID, &sp.Created)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return nil, fmt.Errorf("%w: %s", ErrSpaceExists, ref)
}
return nil, fmt.Errorf("insert space %s: %w", ref, err)
}
return &sp, nil
}
// GetSpace resolves a space by owner and name. Returns ErrNotFound if no such
// space exists.
func (s *Store) GetSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) {
q := spaceSelect + ` WHERE owner = $1 AND name = $2`
sp, err := scanSpace(s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get space %s: %w", ref, err)
}
return sp, nil
}
// GetSpaceByID resolves a space by its primary key — the form every other table
// references it by. Returns ErrNotFound if no such space exists.
func (s *Store) GetSpaceByID(ctx context.Context, id int) (*Space, error) {
q := spaceSelect + ` WHERE id = $1`
sp, err := scanSpace(s.q.QueryRowContext(ctx, q, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get space %d: %w", id, err)
}
return sp, nil
}
// ListSpaces returns every space, ordered by owner then name. There is one human
// on this instance and no visibility levels, so there is nothing to filter by:
// the list is the whole corpus, which is also exactly what the meta-project (a
// filter that excludes nothing) needs.
func (s *Store) ListSpaces(ctx context.Context) ([]*Space, error) {
q := spaceSelect + ` ORDER BY owner, name`
rows, err := s.q.QueryContext(ctx, q)
if err != nil {
return nil, fmt.Errorf("list spaces: %w", err)
}
defer rows.Close()
var spaces []*Space
for rows.Next() {
sp, err := scanSpace(rows)
if err != nil {
return nil, fmt.Errorf("scan space: %w", err)
}
spaces = append(spaces, sp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate spaces: %w", err)
}
return spaces, nil
}