| 1 |
|
package service |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"os" |
| 8 |
|
"time" |
| 9 |
|
|
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/db" |
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/gitx" |
| 13 |
|
) |
| 14 |
|
|
| 15 |
|
// Space is one space, resolved: its reference, its row and its repository. |
| 16 |
|
// |
| 17 |
|
// The repository is the space — "spaces exist as repos" — and the row is |
| 18 |
|
// bookkeeping that makes listing and index staleness cheap. ID is the row's |
| 19 |
|
// primary key, which every other table references the space by; it is zero only |
| 20 |
|
// for a space whose repository exists but whose row does not, a state |
| 21 |
|
// CreateSpace is written to avoid and OpenSpace refuses to invent. |
| 22 |
|
type Space struct { |
| 23 |
|
Ref core.SpaceRef |
| 24 |
|
ID int |
| 25 |
|
Created time.Time |
| 26 |
|
Repo *gitx.Repo |
| 27 |
|
} |
| 28 |
|
|
| 29 |
|
// ApprovedBranch is the branch a document must be reachable from to be |
| 30 |
|
// approved. Read from the repository's HEAD, so there is exactly one place a |
| 31 |
|
// space records it. |
| 32 |
57 |
func (sp *Space) ApprovedBranch() string { return sp.Repo.ApprovedBranch() } |
| 33 |
|
|
| 34 |
|
// CreateSpace creates a space: the bare repository first, then the row. |
| 35 |
|
// |
| 36 |
|
// The order is not arbitrary and it is the opposite of the proposal path's. |
| 37 |
|
// A proposal must be row-first because its branch name derives from the row's |
| 38 |
|
// serial id; a space has no such dependency, and git is authoritative for |
| 39 |
|
// content, so the repository leads: |
| 40 |
|
// |
| 41 |
|
// - repository, then row — a crash in between leaves content on disk that is |
| 42 |
|
// merely unlisted, and owner and name are recoverable from the directory |
| 43 |
|
// name alone. |
| 44 |
|
// - row, then repository — a crash in between leaves a phantom space that |
| 45 |
|
// lists fine and 404s on every read, and db/ exposes no way to delete it. |
| 46 |
|
// |
| 47 |
|
// If the row insert fails, the repository this call just created is removed |
| 48 |
|
// again and the insert's error is returned. That is safe precisely because it |
| 49 |
|
// is seconds old, empty apart from gitx's initial commit, and named nowhere |
| 50 |
|
// yet: nothing can have pushed to it. A cleanup failure is reported alongside |
| 51 |
|
// the original error rather than swallowed. |
| 52 |
46 |
func (s *Service) CreateSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) { |
| 53 |
46 |
if err := core.ValidateOwner(ref.Owner); err != nil { |
| 54 |
2 |
return nil, err |
| 55 |
2 |
} |
| 56 |
44 |
if err := core.ValidateSpaceName(ref.Name); err != nil { |
| 57 |
2 |
return nil, err |
| 58 |
2 |
} |
| 59 |
|
|
| 60 |
42 |
repo, err := gitx.Create(ctx, s.cfg.Repos, ref, gitx.CreateOptions{ |
| 61 |
42 |
Owner: gitx.Signature{ |
| 62 |
42 |
Name: s.cfg.Instance.OwnerName, |
| 63 |
42 |
Email: s.cfg.Instance.OwnerEmail, |
| 64 |
42 |
When: s.now().UTC(), |
| 65 |
42 |
}, |
| 66 |
42 |
}) |
| 67 |
42 |
if err != nil { |
| 68 |
1 |
if errors.Is(err, gitx.ErrExists) { |
| 69 |
1 |
return nil, fmt.Errorf("%w: %w", ErrSpaceExists, err) |
| 70 |
1 |
} |
| 71 |
0 |
return nil, fmt.Errorf("service: create repository for %s: %w", ref, err) |
| 72 |
|
} |
| 73 |
|
|
| 74 |
41 |
row, err := s.store.CreateSpace(ctx, ref) |
| 75 |
41 |
if err != nil { |
| 76 |
1 |
rmErr := os.RemoveAll(repo.Dir()) |
| 77 |
1 |
if rmErr != nil { |
| 78 |
0 |
return nil, fmt.Errorf("service: create row for %s: %w "+ |
| 79 |
0 |
"(the repository at %s could not be removed either: %v — remove it by hand before retrying)", |
| 80 |
0 |
ref, err, repo.Dir(), rmErr) |
| 81 |
0 |
} |
| 82 |
1 |
if errors.Is(err, db.ErrSpaceExists) { |
| 83 |
0 |
return nil, fmt.Errorf("%w: %w", ErrSpaceExists, err) |
| 84 |
0 |
} |
| 85 |
1 |
return nil, fmt.Errorf("service: create row for %s: %w", ref, err) |
| 86 |
|
} |
| 87 |
|
|
| 88 |
40 |
return &Space{Ref: ref, ID: row.ID, Created: row.Created, Repo: repo}, nil |
| 89 |
|
} |
| 90 |
|
|
| 91 |
|
// OpenSpace resolves a space by reference: its row and its repository, both |
| 92 |
|
// required. |
| 93 |
|
// |
| 94 |
|
// A row with no repository, or a repository with no row, is a half-created |
| 95 |
|
// space rather than a space, and is reported as such. Neither half is invented: |
| 96 |
|
// serving reads from a repository with no row would give every document a |
| 97 |
|
// space_id of zero in the index, and returning a row whose repository is |
| 98 |
|
// missing would answer "the space exists" to every question and fail on each |
| 99 |
|
// individual document. |
| 100 |
57 |
func (s *Service) OpenSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) { |
| 101 |
57 |
row, err := s.store.GetSpace(ctx, ref) |
| 102 |
57 |
if err != nil { |
| 103 |
8 |
if errors.Is(err, db.ErrNotFound) { |
| 104 |
1 |
return nil, fmt.Errorf("%w: space %s", ErrNotFound, ref) |
| 105 |
1 |
} |
| 106 |
7 |
return nil, fmt.Errorf("service: look up space %s: %w", ref, err) |
| 107 |
|
} |
| 108 |
49 |
repo, err := s.openRepo(ref) |
| 109 |
49 |
if err != nil { |
| 110 |
1 |
return nil, err |
| 111 |
1 |
} |
| 112 |
48 |
return &Space{Ref: ref, ID: row.ID, Created: row.Created, Repo: repo}, nil |
| 113 |
|
} |
| 114 |
|
|
| 115 |
|
// openRepo opens a space's bare repository, mapping gitx's absence onto this |
| 116 |
|
// package's. Split out because the reconciler resolves repositories for spaces |
| 117 |
|
// it already has rows for. |
| 118 |
52 |
func (s *Service) openRepo(ref core.SpaceRef) (*gitx.Repo, error) { |
| 119 |
52 |
repo, err := gitx.Open(s.cfg.Repos, ref) |
| 120 |
52 |
if err != nil { |
| 121 |
1 |
if errors.Is(err, gitx.ErrNotFound) { |
| 122 |
1 |
return nil, fmt.Errorf("%w: repository for space %s at %s", |
| 123 |
1 |
ErrNotFound, ref, gitx.DiskPath(s.cfg.Repos, ref)) |
| 124 |
1 |
} |
| 125 |
0 |
return nil, fmt.Errorf("service: open repository for %s: %w", ref, err) |
| 126 |
|
} |
| 127 |
51 |
return repo, nil |
| 128 |
|
} |
| 129 |
|
|
| 130 |
|
// ListSpaces returns every space, ordered by owner then name. |
| 131 |
|
// |
| 132 |
|
// There is one human on this instance and no visibility levels, so there is |
| 133 |
|
// nothing to filter by — the list is the whole corpus, which is also exactly |
| 134 |
|
// what the meta-project (a filter that excludes nothing) needs. |
| 135 |
|
// |
| 136 |
|
// Repositories are not opened. Listing is a page of names, and opening N bare |
| 137 |
|
// repositories to render it would make the cheapest view in the service the |
| 138 |
|
// most expensive; callers that need a repository call OpenSpace. Space.Repo is |
| 139 |
|
// therefore nil in every element returned here. |
| 140 |
9 |
func (s *Service) ListSpaces(ctx context.Context) ([]*Space, error) { |
| 141 |
9 |
rows, err := s.store.ListSpaces(ctx) |
| 142 |
9 |
if err != nil { |
| 143 |
1 |
return nil, fmt.Errorf("service: list spaces: %w", err) |
| 144 |
1 |
} |
| 145 |
8 |
out := make([]*Space, 0, len(rows)) |
| 146 |
9 |
for _, row := range rows { |
| 147 |
9 |
out = append(out, &Space{Ref: row.Ref, ID: row.ID, Created: row.Created}) |
| 148 |
9 |
} |
| 149 |
8 |
return out, nil |
| 150 |
|
} |