~bigbes/sr-ht-spec

ref: 3cb1c03d8078d5748cc13a2e9bd7ba7d078e1b37 sr-ht-spec/service/project.go -rw-r--r-- 10.9 KiB
3cb1c03d — Eugene Blikh go.mod: take the shared libraries' current heads 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
package service

import (
	"context"
	"errors"
	"fmt"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/db"
)

// Project is one project: a named set of spaces sharing one search scope, one
// MCP view and one wikilink namespace.
//
// A project is pure metadata — a saved filter, not a container. It owns no
// index and no storage, which is why this type carries no repository, no
// revision and no membership: there is one global bleve index, and querying a
// project means restricting that index to the project's spaces. Resolving a
// project to that restriction is ResolveProject.
type Project struct {
	Ref     core.ProjectRef
	ID      int
	Created time.Time
}

// SpaceFilter is what a project resolves to: the set of spaces a query is
// restricted to. It is the entirety of what a project *does*, and it is
// core.SpaceFilter under this package's name.
//
// It lives in core because search.Query takes the same type. The alternative —
// this package's struct translated into a query's own space list at each
// caller — is where the polarity trap lived: an empty project's membership is
// an empty slice, "no terms" reads as "no restriction" to a query filter, and a
// freshly created project silently became the whole corpus. There is no slice
// to hand over any more; a filter carries its own polarity all the way down.
type SpaceFilter = core.SpaceFilter

// EverythingFilter is the degenerate filter: the meta-project, "merge all my
// doc work into one searchable thing".
//
// It is a value, not a row, and that is the decision this file rests on. A
// stored `+everything` project would have to be kept in step with every
// CreateSpace — a sync job whose one failure mode is a meta-project that
// silently omits a space — and it could be renamed or deleted, which the
// meta-project must not be. As a filter that excludes nothing it needs no
// storage, no migration data and no maintenance, and adding a space to the
// service adds it to the meta-project by construction.
func EverythingFilter() SpaceFilter { return core.EverythingFilter() }

// ResolveProject resolves a project reference to the space filter its queries
// run under. This is the read the whole feature exists for.
//
// The meta-project resolves without touching the database, to the filter that
// excludes nothing. Every other reference resolves to its membership; a project
// with no member spaces resolves to a filter that selects nothing, which is
// what it says.
//
// Returns ErrNotFound if no such project exists.
func (s *Service) ResolveProject(ctx context.Context, ref core.ProjectRef) (SpaceFilter, error) {
	if ref.IsMeta() {
		return EverythingFilter(), nil
	}
	row, err := s.projectRow(ctx, ref)
	if err != nil {
		return SpaceFilter{}, err
	}
	spaces, err := s.store.ProjectSpaces(ctx, row.ID)
	if err != nil {
		return SpaceFilter{}, fmt.Errorf("service: resolve project %s: %w", ref, err)
	}
	refs := make([]core.SpaceRef, 0, len(spaces))
	ids := make([]int, 0, len(spaces))
	for _, sp := range spaces {
		refs = append(refs, sp.Ref)
		ids = append(ids, sp.ID)
	}
	// Named, not "all": a project with no members selects nothing, and the
	// filter says so wherever it is carried.
	return core.SpacesFilter(refs, ids), nil
}

// CreateProject creates an empty project.
//
// Empty is the honest starting state: a project is a saved filter and a filter
// with no terms selects nothing, so a new project returns no results until
// spaces are added to it. Creating it pre-populated with anything would be
// guessing at what the filter is for.
//
// The reserved meta-project name is refused with core.ErrReservedName — it is
// an address that resolves to a filter, and a row of that name could only
// shadow it.
func (s *Service) CreateProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
	if err := core.ValidateOwner(ref.Owner); err != nil {
		return nil, err
	}
	if err := core.ValidateProjectName(ref.Name); err != nil {
		return nil, err
	}
	row, err := s.store.CreateProject(ctx, ref)
	if err != nil {
		if errors.Is(err, db.ErrProjectExists) {
			return nil, fmt.Errorf("%w: %w", ErrProjectExists, err)
		}
		return nil, fmt.Errorf("service: create project %s: %w", ref, err)
	}
	return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
}

// GetProject resolves a project by reference. Returns ErrNotFound if no such
// project exists — including for the meta-project, which has no row; callers
// wanting its filter call ResolveProject, and callers wanting its membership
// call ProjectSpaces.
func (s *Service) GetProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
	row, err := s.projectRow(ctx, ref)
	if err != nil {
		return nil, err
	}
	return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
}

// ListProjects returns every stored project, ordered by owner then name.
//
// The meta-project is not in the list, because it is not a row. A caller
// rendering a project index adds it as the degenerate filter it is, which also
// means it can never be missing, renamed or deleted.
func (s *Service) ListProjects(ctx context.Context) ([]*Project, error) {
	rows, err := s.store.ListProjects(ctx)
	if err != nil {
		return nil, fmt.Errorf("service: list projects: %w", err)
	}
	out := make([]*Project, 0, len(rows))
	for _, row := range rows {
		out = append(out, &Project{Ref: row.Ref, ID: row.ID, Created: row.Created})
	}
	return out, nil
}

// DeleteProject deletes a project and its membership rows.
//
// Nothing else goes: a project owns no index and no storage, so deleting one
// deletes a name and a saved query. Every space it named still exists, still
// holds its documents, and is still in the one global index.
func (s *Service) DeleteProject(ctx context.Context, ref core.ProjectRef) error {
	row, err := s.projectRow(ctx, ref)
	if err != nil {
		return err
	}
	if err := s.store.DeleteProject(ctx, row.ID); err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return fmt.Errorf("%w: project %s", ErrNotFound, ref)
		}
		return fmt.Errorf("service: delete project %s: %w", ref, err)
	}
	return nil
}

// AddSpaceToProject adds a space to a project's filter. Adding a space that is
// already a member changes nothing and is not an error: membership is a set.
//
// Both sides must exist. The space is resolved by its row rather than by
// opening its repository: membership is metadata about a row, and requiring the
// bare repository to be openable would make editing a saved filter fail on a
// space whose repository is being moved.
func (s *Service) AddSpaceToProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
	proj, sp, err := s.projectAndSpace(ctx, ref, space)
	if err != nil {
		return err
	}
	if err := s.store.AddProjectSpace(ctx, proj.ID, sp.ID); err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return fmt.Errorf("%w: project %s or space %s", ErrNotFound, ref, space)
		}
		return fmt.Errorf("service: add %s to project %s: %w", space, ref, err)
	}
	return nil
}

// RemoveSpaceFromProject drops a space from a project's filter, leaving the
// space itself untouched. Returns ErrNotFound if the space was not a member —
// reporting it beats claiming a filter change that did not happen.
func (s *Service) RemoveSpaceFromProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
	proj, sp, err := s.projectAndSpace(ctx, ref, space)
	if err != nil {
		return err
	}
	if err := s.store.RemoveProjectSpace(ctx, proj.ID, sp.ID); err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return fmt.Errorf("%w: space %s is not a member of project %s", ErrNotFound, space, ref)
		}
		return fmt.Errorf("service: remove %s from project %s: %w", space, ref, err)
	}
	return nil
}

// ProjectSpaces lists a project's member spaces, ordered by owner then name.
//
// This is the listing counterpart of ResolveProject, and the difference is not
// cosmetic: a filter is never enumerated (see SpaceFilter), whereas a list is
// enumerated by definition. So the meta-project lists every space here while
// resolving to an unenumerated "everything" there — a page showing what is in
// it wants the names, and a query must not freeze them.
//
// Repositories are not opened, exactly as ListSpaces does not open them.
func (s *Service) ProjectSpaces(ctx context.Context, ref core.ProjectRef) ([]*Space, error) {
	if ref.IsMeta() {
		return s.ListSpaces(ctx)
	}
	row, err := s.projectRow(ctx, ref)
	if err != nil {
		return nil, err
	}
	rows, err := s.store.ProjectSpaces(ctx, row.ID)
	if err != nil {
		return nil, fmt.Errorf("service: list spaces of project %s: %w", ref, err)
	}
	out := make([]*Space, 0, len(rows))
	for _, sp := range rows {
		out = append(out, &Space{Ref: sp.Ref, ID: sp.ID, Created: sp.Created})
	}
	return out, nil
}

// ProjectsForSpace returns every project a space belongs to, ordered by owner
// then name. A space belongs to any number of them, including none — and to
// the meta-project always, which is not listed here because it is not a row.
func (s *Service) ProjectsForSpace(ctx context.Context, space core.SpaceRef) ([]*Project, error) {
	row, err := s.store.GetSpace(ctx, space)
	if err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
		}
		return nil, fmt.Errorf("service: look up space %s: %w", space, err)
	}
	rows, err := s.store.ProjectsBySpace(ctx, row.ID)
	if err != nil {
		return nil, fmt.Errorf("service: list projects of space %s: %w", space, err)
	}
	out := make([]*Project, 0, len(rows))
	for _, p := range rows {
		out = append(out, &Project{Ref: p.Ref, ID: p.ID, Created: p.Created})
	}
	return out, nil
}

// projectRow resolves a project reference to its row, mapping db's absence onto
// this package's. The meta-project is refused here rather than at each caller:
// it has no row, and every operation that needs one — get, delete, membership
// editing — is an operation on storage that the meta-project deliberately does
// not have.
func (s *Service) projectRow(ctx context.Context, ref core.ProjectRef) (*db.Project, error) {
	if ref.IsMeta() {
		return nil, fmt.Errorf("%w: project %s is the meta-project, a filter over every space rather than a row",
			ErrNotFound, ref)
	}
	row, err := s.store.GetProject(ctx, ref)
	if err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return nil, fmt.Errorf("%w: project %s", ErrNotFound, ref)
		}
		return nil, fmt.Errorf("service: look up project %s: %w", ref, err)
	}
	return row, nil
}

// projectAndSpace resolves both sides of a membership edit, so the two doors
// that perform one report a missing project and a missing space the same way.
func (s *Service) projectAndSpace(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) (*db.Project, *db.Space, error) {
	proj, err := s.projectRow(ctx, ref)
	if err != nil {
		return nil, nil, err
	}
	sp, err := s.store.GetSpace(ctx, space)
	if err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return nil, nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
		}
		return nil, nil, fmt.Errorf("service: look up space %s: %w", space, err)
	}
	return proj, sp, nil
}