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