coverage~bigbes/sr-ht-spec3cb1c03dservice/space.go

Coverage
90.2% 37/41 statements
Δ
Blob
97b1c08
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 }