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

Coverage
83.2% 84/101 statements
Δ
Blob
5b6ad15
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/core"
10 "sourcecraft.dev/bigbes/sr-ht-spec/db"
11 )
12
13 // Project is one project: a named set of spaces sharing one search scope, one
14 // MCP view and one wikilink namespace.
15 //
16 // A project is pure metadata — a saved filter, not a container. It owns no
17 // index and no storage, which is why this type carries no repository, no
18 // revision and no membership: there is one global bleve index, and querying a
19 // project means restricting that index to the project's spaces. Resolving a
20 // project to that restriction is ResolveProject.
21 type Project struct {
22 Ref core.ProjectRef
23 ID int
24 Created time.Time
25 }
26
27 // SpaceFilter is what a project resolves to: the set of spaces a query is
28 // restricted to. It is the entirety of what a project *does*, and it is
29 // core.SpaceFilter under this package's name.
30 //
31 // It lives in core because search.Query takes the same type. The alternative —
32 // this package's struct translated into a query's own space list at each
33 // caller — is where the polarity trap lived: an empty project's membership is
34 // an empty slice, "no terms" reads as "no restriction" to a query filter, and a
35 // freshly created project silently became the whole corpus. There is no slice
36 // to hand over any more; a filter carries its own polarity all the way down.
37 type SpaceFilter = core.SpaceFilter
38
39 // EverythingFilter is the degenerate filter: the meta-project, "merge all my
40 // doc work into one searchable thing".
41 //
42 // It is a value, not a row, and that is the decision this file rests on. A
43 // stored `+everything` project would have to be kept in step with every
44 // CreateSpace — a sync job whose one failure mode is a meta-project that
45 // silently omits a space — and it could be renamed or deleted, which the
46 // meta-project must not be. As a filter that excludes nothing it needs no
47 // storage, no migration data and no maintenance, and adding a space to the
48 // service adds it to the meta-project by construction.
49 1 func EverythingFilter() SpaceFilter { return core.EverythingFilter() }
50
51 // ResolveProject resolves a project reference to the space filter its queries
52 // run under. This is the read the whole feature exists for.
53 //
54 // The meta-project resolves without touching the database, to the filter that
55 // excludes nothing. Every other reference resolves to its membership; a project
56 // with no member spaces resolves to a filter that selects nothing, which is
57 // what it says.
58 //
59 // Returns ErrNotFound if no such project exists.
60 4 func (s *Service) ResolveProject(ctx context.Context, ref core.ProjectRef) (SpaceFilter, error) {
61 4 if ref.IsMeta() {
62 1 return EverythingFilter(), nil
63 1 }
64 3 row, err := s.projectRow(ctx, ref)
65 3 if err != nil {
66 1 return SpaceFilter{}, err
67 1 }
68 2 spaces, err := s.store.ProjectSpaces(ctx, row.ID)
69 2 if err != nil {
70 0 return SpaceFilter{}, fmt.Errorf("service: resolve project %s: %w", ref, err)
71 0 }
72 2 refs := make([]core.SpaceRef, 0, len(spaces))
73 2 ids := make([]int, 0, len(spaces))
74 2 for _, sp := range spaces {
75 1 refs = append(refs, sp.Ref)
76 1 ids = append(ids, sp.ID)
77 1 }
78 // Named, not "all": a project with no members selects nothing, and the
79 // filter says so wherever it is carried.
80 2 return core.SpacesFilter(refs, ids), nil
81 }
82
83 // CreateProject creates an empty project.
84 //
85 // Empty is the honest starting state: a project is a saved filter and a filter
86 // with no terms selects nothing, so a new project returns no results until
87 // spaces are added to it. Creating it pre-populated with anything would be
88 // guessing at what the filter is for.
89 //
90 // The reserved meta-project name is refused with core.ErrReservedName — it is
91 // an address that resolves to a filter, and a row of that name could only
92 // shadow it.
93 7 func (s *Service) CreateProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
94 7 if err := core.ValidateOwner(ref.Owner); err != nil {
95 0 return nil, err
96 0 }
97 7 if err := core.ValidateProjectName(ref.Name); err != nil {
98 1 return nil, err
99 1 }
100 6 row, err := s.store.CreateProject(ctx, ref)
101 6 if err != nil {
102 1 if errors.Is(err, db.ErrProjectExists) {
103 1 return nil, fmt.Errorf("%w: %w", ErrProjectExists, err)
104 1 }
105 0 return nil, fmt.Errorf("service: create project %s: %w", ref, err)
106 }
107 5 return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
108 }
109
110 // GetProject resolves a project by reference. Returns ErrNotFound if no such
111 // project exists — including for the meta-project, which has no row; callers
112 // wanting its filter call ResolveProject, and callers wanting its membership
113 // call ProjectSpaces.
114 3 func (s *Service) GetProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
115 3 row, err := s.projectRow(ctx, ref)
116 3 if err != nil {
117 2 return nil, err
118 2 }
119 1 return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
120 }
121
122 // ListProjects returns every stored project, ordered by owner then name.
123 //
124 // The meta-project is not in the list, because it is not a row. A caller
125 // rendering a project index adds it as the degenerate filter it is, which also
126 // means it can never be missing, renamed or deleted.
127 2 func (s *Service) ListProjects(ctx context.Context) ([]*Project, error) {
128 2 rows, err := s.store.ListProjects(ctx)
129 2 if err != nil {
130 0 return nil, fmt.Errorf("service: list projects: %w", err)
131 0 }
132 2 out := make([]*Project, 0, len(rows))
133 2 for _, row := range rows {
134 1 out = append(out, &Project{Ref: row.Ref, ID: row.ID, Created: row.Created})
135 1 }
136 2 return out, nil
137 }
138
139 // DeleteProject deletes a project and its membership rows.
140 //
141 // Nothing else goes: a project owns no index and no storage, so deleting one
142 // deletes a name and a saved query. Every space it named still exists, still
143 // holds its documents, and is still in the one global index.
144 3 func (s *Service) DeleteProject(ctx context.Context, ref core.ProjectRef) error {
145 3 row, err := s.projectRow(ctx, ref)
146 3 if err != nil {
147 2 return err
148 2 }
149 1 if err := s.store.DeleteProject(ctx, row.ID); err != nil {
150 0 if errors.Is(err, db.ErrNotFound) {
151 0 return fmt.Errorf("%w: project %s", ErrNotFound, ref)
152 0 }
153 0 return fmt.Errorf("service: delete project %s: %w", ref, err)
154 }
155 1 return nil
156 }
157
158 // AddSpaceToProject adds a space to a project's filter. Adding a space that is
159 // already a member changes nothing and is not an error: membership is a set.
160 //
161 // Both sides must exist. The space is resolved by its row rather than by
162 // opening its repository: membership is metadata about a row, and requiring the
163 // bare repository to be openable would make editing a saved filter fail on a
164 // space whose repository is being moved.
165 7 func (s *Service) AddSpaceToProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
166 7 proj, sp, err := s.projectAndSpace(ctx, ref, space)
167 7 if err != nil {
168 3 return err
169 3 }
170 4 if err := s.store.AddProjectSpace(ctx, proj.ID, sp.ID); err != nil {
171 0 if errors.Is(err, db.ErrNotFound) {
172 0 return fmt.Errorf("%w: project %s or space %s", ErrNotFound, ref, space)
173 0 }
174 0 return fmt.Errorf("service: add %s to project %s: %w", space, ref, err)
175 }
176 4 return nil
177 }
178
179 // RemoveSpaceFromProject drops a space from a project's filter, leaving the
180 // space itself untouched. Returns ErrNotFound if the space was not a member —
181 // reporting it beats claiming a filter change that did not happen.
182 3 func (s *Service) RemoveSpaceFromProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
183 3 proj, sp, err := s.projectAndSpace(ctx, ref, space)
184 3 if err != nil {
185 1 return err
186 1 }
187 2 if err := s.store.RemoveProjectSpace(ctx, proj.ID, sp.ID); err != nil {
188 1 if errors.Is(err, db.ErrNotFound) {
189 1 return fmt.Errorf("%w: space %s is not a member of project %s", ErrNotFound, space, ref)
190 1 }
191 0 return fmt.Errorf("service: remove %s from project %s: %w", space, ref, err)
192 }
193 1 return nil
194 }
195
196 // ProjectSpaces lists a project's member spaces, ordered by owner then name.
197 //
198 // This is the listing counterpart of ResolveProject, and the difference is not
199 // cosmetic: a filter is never enumerated (see SpaceFilter), whereas a list is
200 // enumerated by definition. So the meta-project lists every space here while
201 // resolving to an unenumerated "everything" there — a page showing what is in
202 // it wants the names, and a query must not freeze them.
203 //
204 // Repositories are not opened, exactly as ListSpaces does not open them.
205 3 func (s *Service) ProjectSpaces(ctx context.Context, ref core.ProjectRef) ([]*Space, error) {
206 3 if ref.IsMeta() {
207 2 return s.ListSpaces(ctx)
208 2 }
209 1 row, err := s.projectRow(ctx, ref)
210 1 if err != nil {
211 0 return nil, err
212 0 }
213 1 rows, err := s.store.ProjectSpaces(ctx, row.ID)
214 1 if err != nil {
215 0 return nil, fmt.Errorf("service: list spaces of project %s: %w", ref, err)
216 0 }
217 1 out := make([]*Space, 0, len(rows))
218 1 for _, sp := range rows {
219 1 out = append(out, &Space{Ref: sp.Ref, ID: sp.ID, Created: sp.Created})
220 1 }
221 1 return out, nil
222 }
223
224 // ProjectsForSpace returns every project a space belongs to, ordered by owner
225 // then name. A space belongs to any number of them, including none — and to
226 // the meta-project always, which is not listed here because it is not a row.
227 3 func (s *Service) ProjectsForSpace(ctx context.Context, space core.SpaceRef) ([]*Project, error) {
228 3 row, err := s.store.GetSpace(ctx, space)
229 3 if err != nil {
230 1 if errors.Is(err, db.ErrNotFound) {
231 1 return nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
232 1 }
233 0 return nil, fmt.Errorf("service: look up space %s: %w", space, err)
234 }
235 2 rows, err := s.store.ProjectsBySpace(ctx, row.ID)
236 2 if err != nil {
237 0 return nil, fmt.Errorf("service: list projects of space %s: %w", space, err)
238 0 }
239 2 out := make([]*Project, 0, len(rows))
240 2 for _, p := range rows {
241 2 out = append(out, &Project{Ref: p.Ref, ID: p.ID, Created: p.Created})
242 2 }
243 2 return out, nil
244 }
245
246 // projectRow resolves a project reference to its row, mapping db's absence onto
247 // this package's. The meta-project is refused here rather than at each caller:
248 // it has no row, and every operation that needs one — get, delete, membership
249 // editing — is an operation on storage that the meta-project deliberately does
250 // not have.
251 20 func (s *Service) projectRow(ctx context.Context, ref core.ProjectRef) (*db.Project, error) {
252 20 if ref.IsMeta() {
253 4 return nil, fmt.Errorf("%w: project %s is the meta-project, a filter over every space rather than a row",
254 4 ErrNotFound, ref)
255 4 }
256 16 row, err := s.store.GetProject(ctx, ref)
257 16 if err != nil {
258 4 if errors.Is(err, db.ErrNotFound) {
259 4 return nil, fmt.Errorf("%w: project %s", ErrNotFound, ref)
260 4 }
261 0 return nil, fmt.Errorf("service: look up project %s: %w", ref, err)
262 }
263 12 return row, nil
264 }
265
266 // projectAndSpace resolves both sides of a membership edit, so the two doors
267 // that perform one report a missing project and a missing space the same way.
268 10 func (s *Service) projectAndSpace(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) (*db.Project, *db.Space, error) {
269 10 proj, err := s.projectRow(ctx, ref)
270 10 if err != nil {
271 3 return nil, nil, err
272 3 }
273 7 sp, err := s.store.GetSpace(ctx, space)
274 7 if err != nil {
275 1 if errors.Is(err, db.ErrNotFound) {
276 1 return nil, nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
277 1 }
278 0 return nil, nil, fmt.Errorf("service: look up space %s: %w", space, err)
279 }
280 6 return proj, sp, nil
281 }