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

Coverage
81.9% 68/83 statements
Δ
Blob
ea88d46
1 package db
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "time"
9
10 "github.com/lib/pq"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/core"
13 )
14
15 // Project is a named set of spaces: one search scope, one MCP view, one
16 // wikilink namespace.
17 //
18 // A project is **pure metadata — a saved filter, not a container**. It owns no
19 // index and no storage, so this row is a name and project_space is the filter.
20 // Nothing here is a parent of a space: a space belongs to any number of
21 // projects, or none, and deleting a project deletes no content.
22 //
23 // In particular a project does not scope document IDs. Those are global, and
24 // deliberately so: projects are edited after merges, so a per-project registry
25 // could juxtapose two already-merged documents claiming one identity with no
26 // merge left to reject.
27 type Project struct {
28 ID int
29 Ref core.ProjectRef
30 Created time.Time
31 }
32
33 const projectSelect = `SELECT id, owner, name, created FROM project`
34
35 9 func scanProject(sc rowScanner) (*Project, error) {
36 9 var p Project
37 9 if err := sc.Scan(&p.ID, &p.Ref.Owner, &p.Ref.Name, &p.Created); err != nil {
38 2 return nil, err
39 2 }
40 7 return &p, nil
41 }
42
43 // CreateProject inserts a project row with no member spaces.
44 //
45 // The name is validated with core first, which also refuses the reserved
46 // meta-project name: "~owner/+everything" is an address that resolves to a
47 // filter excluding nothing, so a row claiming it could only shadow it — and
48 // keeping it a row would need the sync job the design says the meta-project
49 // does not have.
50 //
51 // A uq_project_owner_name violation maps to ErrProjectExists.
52 12 func (s *Store) CreateProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
53 12 if err := core.ValidateOwner(ref.Owner); err != nil {
54 0 return nil, err
55 0 }
56 12 if err := core.ValidateProjectName(ref.Name); err != nil {
57 2 return nil, err
58 2 }
59 10 const q = `
60 10 INSERT INTO project (owner, name, created)
61 10 VALUES ($1, $2, $3)
62 10 RETURNING id, created`
63 10 var p Project
64 10 p.Ref = ref
65 10 err := s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name, time.Now().UTC()).
66 10 Scan(&p.ID, &p.Created)
67 10 if err != nil {
68 1 var pqErr *pq.Error
69 1 if errors.As(err, &pqErr) && pqErr.Code == "23505" {
70 1 return nil, fmt.Errorf("%w: %s", ErrProjectExists, ref)
71 1 }
72 0 return nil, fmt.Errorf("insert project %s: %w", ref, err)
73 }
74 9 return &p, nil
75 }
76
77 // GetProject resolves a project by owner and name. Returns ErrNotFound if no
78 // such project exists.
79 3 func (s *Store) GetProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
80 3 q := projectSelect + ` WHERE owner = $1 AND name = $2`
81 3 p, err := scanProject(s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name))
82 3 if errors.Is(err, sql.ErrNoRows) {
83 2 return nil, ErrNotFound
84 2 }
85 1 if err != nil {
86 0 return nil, fmt.Errorf("get project %s: %w", ref, err)
87 0 }
88 1 return p, nil
89 }
90
91 // ListProjects returns every project, ordered by owner then name.
92 //
93 // The meta-project is not among them, and must not be: it has no row. A caller
94 // rendering a project list adds it as the degenerate filter it is.
95 1 func (s *Store) ListProjects(ctx context.Context) ([]*Project, error) {
96 1 q := projectSelect + ` ORDER BY owner, name`
97 1 rows, err := s.q.QueryContext(ctx, q)
98 1 if err != nil {
99 0 return nil, fmt.Errorf("list projects: %w", err)
100 0 }
101 1 defer rows.Close()
102 1 var projects []*Project
103 3 for rows.Next() {
104 3 p, err := scanProject(rows)
105 3 if err != nil {
106 0 return nil, fmt.Errorf("scan project: %w", err)
107 0 }
108 3 projects = append(projects, p)
109 }
110 1 if err := rows.Err(); err != nil {
111 0 return nil, fmt.Errorf("iterate projects: %w", err)
112 0 }
113 1 return projects, nil
114 }
115
116 // DeleteProject removes a project and, by cascade, its membership rows. No
117 // space, document or index entry is touched — a project is a saved filter, so
118 // deleting one deletes a name and a query, never content.
119 //
120 // Returns ErrNotFound if no such project exists.
121 3 func (s *Store) DeleteProject(ctx context.Context, projectID int) error {
122 3 res, err := s.q.ExecContext(ctx, `DELETE FROM project WHERE id = $1`, projectID)
123 3 if err != nil {
124 0 return fmt.Errorf("delete project %d: %w", projectID, err)
125 0 }
126 3 return requireOne(res, "delete project")
127 }
128
129 // AddProjectSpace adds a space to a project's filter.
130 //
131 // Membership is a set, so adding a space that is already a member succeeds and
132 // changes nothing: "the space is in the project" is true either way, and there
133 // is no state for a second copy to occupy. This is not a swallowed conflict —
134 // the composite primary key is what makes the duplicate unrepresentable, and
135 // this method says so out loud.
136 //
137 // A project_id or space_id with no row behind it violates a foreign key and is
138 // reported as ErrNotFound: a filter term pointing at nothing would silently
139 // narrow every query made through it.
140 10 func (s *Store) AddProjectSpace(ctx context.Context, projectID, spaceID int) error {
141 10 const q = `
142 10 INSERT INTO project_space (project_id, space_id)
143 10 VALUES ($1, $2)
144 10 ON CONFLICT (project_id, space_id) DO NOTHING`
145 10 if _, err := s.q.ExecContext(ctx, q, projectID, spaceID); err != nil {
146 2 var pqErr *pq.Error
147 2 if errors.As(err, &pqErr) && pqErr.Code == "23503" {
148 2 return fmt.Errorf("%w: project %d or space %d", ErrNotFound, projectID, spaceID)
149 2 }
150 0 return fmt.Errorf("add space %d to project %d: %w", spaceID, projectID, err)
151 }
152 8 return nil
153 }
154
155 // RemoveProjectSpace drops a space from a project's filter. The space itself is
156 // untouched; only the filter term goes.
157 //
158 // Returns ErrNotFound when the space was not a member. Unlike adding, removing
159 // is not idempotent: "remove what is not there" is either a caller working from
160 // a stale membership list or a concurrent edit, and both are worth hearing
161 // about, whereas a silent success reports a filter change that did not happen.
162 2 func (s *Store) RemoveProjectSpace(ctx context.Context, projectID, spaceID int) error {
163 2 const q = `DELETE FROM project_space WHERE project_id = $1 AND space_id = $2`
164 2 res, err := s.q.ExecContext(ctx, q, projectID, spaceID)
165 2 if err != nil {
166 0 return fmt.Errorf("remove space %d from project %d: %w", spaceID, projectID, err)
167 0 }
168 2 return requireOne(res, "remove project space")
169 }
170
171 // ProjectSpaces resolves a project to its member spaces, ordered by owner then
172 // name. This is the filter, and it is the whole of what a project *is*: a
173 // project query is the one global index restricted to these spaces.
174 //
175 // The rows are returned rather than bare ids because both forms are needed at
176 // once — the index filters by space reference, while document_id, proposal and
177 // index_stamp all key off the id — and one join is cheaper than resolving each
178 // id afterwards.
179 //
180 // An empty result means the project selects no spaces. It emphatically does not
181 // mean "everything": that would turn an empty saved filter into the whole
182 // corpus, which is the opposite of what its author asked for. Callers must not
183 // collapse the two.
184 4 func (s *Store) ProjectSpaces(ctx context.Context, projectID int) ([]*Space, error) {
185 4 const q = `
186 4 SELECT s.id, s.owner, s.name, s.created
187 4 FROM space s
188 4 JOIN project_space ps ON ps.space_id = s.id
189 4 WHERE ps.project_id = $1
190 4 ORDER BY s.owner, s.name`
191 4 rows, err := s.q.QueryContext(ctx, q, projectID)
192 4 if err != nil {
193 0 return nil, fmt.Errorf("list spaces of project %d: %w", projectID, err)
194 0 }
195 4 defer rows.Close()
196 4 var spaces []*Space
197 4 for rows.Next() {
198 4 sp, err := scanSpace(rows)
199 4 if err != nil {
200 0 return nil, fmt.Errorf("scan project space: %w", err)
201 0 }
202 4 spaces = append(spaces, sp)
203 }
204 4 if err := rows.Err(); err != nil {
205 0 return nil, fmt.Errorf("iterate project spaces: %w", err)
206 0 }
207 4 return spaces, nil
208 }
209
210 // ProjectsBySpace returns every project a space belongs to, ordered by owner
211 // then name. A space belongs to any number of them, including none.
212 //
213 // This is the direction the write side asks about: after a merge, "which saved
214 // filters just changed" is this query — though nothing is reindexed per project,
215 // because there is one index and every project containing the space sees the
216 // change for free.
217 3 func (s *Store) ProjectsBySpace(ctx context.Context, spaceID int) ([]*Project, error) {
218 3 const q = `
219 3 SELECT p.id, p.owner, p.name, p.created
220 3 FROM project p
221 3 JOIN project_space ps ON ps.project_id = p.id
222 3 WHERE ps.space_id = $1
223 3 ORDER BY p.owner, p.name`
224 3 rows, err := s.q.QueryContext(ctx, q, spaceID)
225 3 if err != nil {
226 0 return nil, fmt.Errorf("list projects of space %d: %w", spaceID, err)
227 0 }
228 3 defer rows.Close()
229 3 var projects []*Project
230 3 for rows.Next() {
231 3 p, err := scanProject(rows)
232 3 if err != nil {
233 0 return nil, fmt.Errorf("scan project: %w", err)
234 0 }
235 3 projects = append(projects, p)
236 }
237 3 if err := rows.Err(); err != nil {
238 0 return nil, fmt.Errorf("iterate projects: %w", err)
239 0 }
240 3 return projects, nil
241 }