| 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 |
|
} |