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 }