package service
import (
"context"
"errors"
"fmt"
"os"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
// Space is one space, resolved: its reference, its row and its repository.
//
// The repository is the space — "spaces exist as repos" — and the row is
// bookkeeping that makes listing and index staleness cheap. ID is the row's
// primary key, which every other table references the space by; it is zero only
// for a space whose repository exists but whose row does not, a state
// CreateSpace is written to avoid and OpenSpace refuses to invent.
type Space struct {
Ref core.SpaceRef
ID int
Created time.Time
Repo *gitx.Repo
}
// ApprovedBranch is the branch a document must be reachable from to be
// approved. Read from the repository's HEAD, so there is exactly one place a
// space records it.
func (sp *Space) ApprovedBranch() string { return sp.Repo.ApprovedBranch() }
// CreateSpace creates a space: the bare repository first, then the row.
//
// The order is not arbitrary and it is the opposite of the proposal path's.
// A proposal must be row-first because its branch name derives from the row's
// serial id; a space has no such dependency, and git is authoritative for
// content, so the repository leads:
//
// - repository, then row — a crash in between leaves content on disk that is
// merely unlisted, and owner and name are recoverable from the directory
// name alone.
// - row, then repository — a crash in between leaves a phantom space that
// lists fine and 404s on every read, and db/ exposes no way to delete it.
//
// If the row insert fails, the repository this call just created is removed
// again and the insert's error is returned. That is safe precisely because it
// is seconds old, empty apart from gitx's initial commit, and named nowhere
// yet: nothing can have pushed to it. A cleanup failure is reported alongside
// the original error rather than swallowed.
func (s *Service) 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
}
repo, err := gitx.Create(ctx, s.cfg.Repos, ref, gitx.CreateOptions{
Owner: gitx.Signature{
Name: s.cfg.Instance.OwnerName,
Email: s.cfg.Instance.OwnerEmail,
When: s.now().UTC(),
},
})
if err != nil {
if errors.Is(err, gitx.ErrExists) {
return nil, fmt.Errorf("%w: %w", ErrSpaceExists, err)
}
return nil, fmt.Errorf("service: create repository for %s: %w", ref, err)
}
row, err := s.store.CreateSpace(ctx, ref)
if err != nil {
rmErr := os.RemoveAll(repo.Dir())
if rmErr != nil {
return nil, fmt.Errorf("service: create row for %s: %w "+
"(the repository at %s could not be removed either: %v — remove it by hand before retrying)",
ref, err, repo.Dir(), rmErr)
}
if errors.Is(err, db.ErrSpaceExists) {
return nil, fmt.Errorf("%w: %w", ErrSpaceExists, err)
}
return nil, fmt.Errorf("service: create row for %s: %w", ref, err)
}
return &Space{Ref: ref, ID: row.ID, Created: row.Created, Repo: repo}, nil
}
// OpenSpace resolves a space by reference: its row and its repository, both
// required.
//
// A row with no repository, or a repository with no row, is a half-created
// space rather than a space, and is reported as such. Neither half is invented:
// serving reads from a repository with no row would give every document a
// space_id of zero in the index, and returning a row whose repository is
// missing would answer "the space exists" to every question and fail on each
// individual document.
func (s *Service) OpenSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) {
row, err := s.store.GetSpace(ctx, ref)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("%w: space %s", ErrNotFound, ref)
}
return nil, fmt.Errorf("service: look up space %s: %w", ref, err)
}
repo, err := s.openRepo(ref)
if err != nil {
return nil, err
}
return &Space{Ref: ref, ID: row.ID, Created: row.Created, Repo: repo}, nil
}
// openRepo opens a space's bare repository, mapping gitx's absence onto this
// package's. Split out because the reconciler resolves repositories for spaces
// it already has rows for.
func (s *Service) openRepo(ref core.SpaceRef) (*gitx.Repo, error) {
repo, err := gitx.Open(s.cfg.Repos, ref)
if err != nil {
if errors.Is(err, gitx.ErrNotFound) {
return nil, fmt.Errorf("%w: repository for space %s at %s",
ErrNotFound, ref, gitx.DiskPath(s.cfg.Repos, ref))
}
return nil, fmt.Errorf("service: open repository for %s: %w", ref, err)
}
return repo, 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.
//
// Repositories are not opened. Listing is a page of names, and opening N bare
// repositories to render it would make the cheapest view in the service the
// most expensive; callers that need a repository call OpenSpace. Space.Repo is
// therefore nil in every element returned here.
func (s *Service) ListSpaces(ctx context.Context) ([]*Space, error) {
rows, err := s.store.ListSpaces(ctx)
if err != nil {
return nil, fmt.Errorf("service: list spaces: %w", err)
}
out := make([]*Space, 0, len(rows))
for _, row := range rows {
out = append(out, &Space{Ref: row.Ref, ID: row.ID, Created: row.Created})
}
return out, nil
}