| 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-dolt/core" |
| 13 |
|
) |
| 14 |
|
|
| 15 |
|
// repoSelect is the common projection used by every repository read. It joins |
| 16 |
|
// "user" to resolve the owner's username (core.Repo.OwnerName). description is |
| 17 |
|
// nullable in the schema, so it is coalesced to the empty string. |
| 18 |
|
const repoSelect = ` |
| 19 |
|
SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id, |
| 20 |
|
COALESCE(u.username, ''), r.path, r.visibility, r.created, r.updated |
| 21 |
|
FROM repository r |
| 22 |
|
JOIN "user" u ON u.id = r.owner_id` |
| 23 |
|
|
| 24 |
42 |
func scanRepo(sc rowScanner) (*core.Repo, error) { |
| 25 |
42 |
var ( |
| 26 |
42 |
r core.Repo |
| 27 |
42 |
visibility string |
| 28 |
42 |
) |
| 29 |
42 |
if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID, |
| 30 |
42 |
&r.OwnerName, &r.Path, &visibility, &r.Created, &r.Updated); err != nil { |
| 31 |
4 |
return nil, err |
| 32 |
4 |
} |
| 33 |
38 |
r.Visibility = core.Visibility(visibility) |
| 34 |
38 |
return &r, nil |
| 35 |
|
} |
| 36 |
|
|
| 37 |
|
// CreateRepo inserts a new repository row. It is normally run on a transaction |
| 38 |
|
// (Store.WithTx) so the caller can create the on-disk NBS store in the same |
| 39 |
|
// unit of work and roll both back on failure. r.Name, r.OwnerID, r.Path and |
| 40 |
|
// r.Visibility must be set; ID, created and updated are assigned here and the |
| 41 |
|
// populated repo is returned (with OwnerName carried through from the input, |
| 42 |
|
// which the caller already knows). |
| 43 |
|
// |
| 44 |
|
// Both unique violations that a same-name re-create can raise are mapped to |
| 45 |
|
// ErrNameTaken: uq_repo_owner_id_name and repository_path_key. The path index |
| 46 |
|
// is redundant with (owner_id, name) — Path is always derived from the pair by |
| 47 |
|
// RepoDiskPath — but it is declared inline on the column, so its index has the |
| 48 |
|
// lower OID and Postgres reports *it* first. Matching only the named constraint |
| 49 |
|
// therefore never fired in practice and every duplicate surfaced as a raw |
| 50 |
|
// 23505: a 500 on /internal/repos for each push of an existing companion, a 500 |
| 51 |
|
// instead of 409 on the web create form, and a failed adopt on a lost |
| 52 |
|
// remotesapi auto-create race. Any other unique violation is returned unwrapped |
| 53 |
|
// so the caller still sees the true cause. |
| 54 |
27 |
func (s *Store) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) { |
| 55 |
27 |
now := time.Now().UTC() |
| 56 |
27 |
const q = ` |
| 57 |
27 |
INSERT INTO repository (created, updated, name, description, owner_id, path, visibility) |
| 58 |
27 |
VALUES ($1, $1, $2, $3, $4, $5, $6) |
| 59 |
27 |
RETURNING id` |
| 60 |
27 |
var desc any |
| 61 |
27 |
if r.Description != "" { |
| 62 |
0 |
desc = r.Description |
| 63 |
0 |
} |
| 64 |
27 |
var id int |
| 65 |
27 |
err := s.q.QueryRowContext(ctx, q, |
| 66 |
27 |
now, r.Name, desc, r.OwnerID, r.Path, string(r.Visibility)).Scan(&id) |
| 67 |
27 |
if err != nil { |
| 68 |
2 |
var pqErr *pq.Error |
| 69 |
2 |
if errors.As(err, &pqErr) && pqErr.Code == "23505" && |
| 70 |
2 |
(pqErr.Constraint == "uq_repo_owner_id_name" || |
| 71 |
2 |
pqErr.Constraint == "repository_path_key") { |
| 72 |
2 |
return nil, ErrNameTaken |
| 73 |
2 |
} |
| 74 |
0 |
return nil, fmt.Errorf("insert repository: %w", err) |
| 75 |
|
} |
| 76 |
25 |
out := *r |
| 77 |
25 |
out.ID = id |
| 78 |
25 |
out.Created, out.Updated = now, now |
| 79 |
25 |
return &out, nil |
| 80 |
|
} |
| 81 |
|
|
| 82 |
|
// GetRepoByOwnerAndName resolves a repository by its owner's username and name. |
| 83 |
|
// Returns ErrNotFound if no such repository exists. |
| 84 |
5 |
func (s *Store) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) { |
| 85 |
5 |
q := repoSelect + ` |
| 86 |
5 |
WHERE u.username = $1 AND r.name = $2` |
| 87 |
5 |
repo, err := scanRepo(s.q.QueryRowContext(ctx, q, ownerUsername, name)) |
| 88 |
5 |
if errors.Is(err, sql.ErrNoRows) { |
| 89 |
2 |
return nil, ErrNotFound |
| 90 |
2 |
} |
| 91 |
3 |
if err != nil { |
| 92 |
0 |
return nil, fmt.Errorf("get repo %s/%s: %w", ownerUsername, name, err) |
| 93 |
0 |
} |
| 94 |
3 |
return repo, nil |
| 95 |
|
} |
| 96 |
|
|
| 97 |
|
// GetRepoByID resolves a repository by its primary key. Returns ErrNotFound if |
| 98 |
|
// no such repository exists. |
| 99 |
9 |
func (s *Store) GetRepoByID(ctx context.Context, id int) (*core.Repo, error) { |
| 100 |
9 |
q := repoSelect + ` |
| 101 |
9 |
WHERE r.id = $1` |
| 102 |
9 |
repo, err := scanRepo(s.q.QueryRowContext(ctx, q, id)) |
| 103 |
9 |
if errors.Is(err, sql.ErrNoRows) { |
| 104 |
2 |
return nil, ErrNotFound |
| 105 |
2 |
} |
| 106 |
7 |
if err != nil { |
| 107 |
0 |
return nil, fmt.Errorf("get repo %d: %w", id, err) |
| 108 |
0 |
} |
| 109 |
7 |
return repo, nil |
| 110 |
|
} |
| 111 |
|
|
| 112 |
|
// ListReposByOwner lists the repositories owned by ownerUsername that viewer is |
| 113 |
|
// allowed to see, newest first. The listing rule (distinct from clone/browse |
| 114 |
|
// authorization) is: |
| 115 |
|
// |
| 116 |
|
// - The owner, and any user holding an ACL entry on a repo, always see it |
| 117 |
|
// regardless of visibility (including PRIVATE and UNLISTED). |
| 118 |
|
// - Everyone else — including anonymous viewers (viewer == nil) — sees only |
| 119 |
|
// PUBLIC repositories. UNLISTED repositories are never listed to non-owners |
| 120 |
|
// without an ACL, and PRIVATE ones are never listed either. |
| 121 |
|
// |
| 122 |
|
// viewer is the browsing principal; pass nil for an anonymous request. |
| 123 |
5 |
func (s *Store) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) { |
| 124 |
5 |
viewerID := 0 |
| 125 |
5 |
if viewer != nil { |
| 126 |
3 |
viewerID = viewer.UserID |
| 127 |
3 |
} |
| 128 |
5 |
q := repoSelect + ` |
| 129 |
5 |
WHERE u.username = $1 AND ( |
| 130 |
5 |
r.visibility = 'PUBLIC' |
| 131 |
5 |
OR r.owner_id = $2 |
| 132 |
5 |
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $2) |
| 133 |
5 |
) |
| 134 |
5 |
ORDER BY r.created DESC, r.id DESC` |
| 135 |
5 |
return s.queryRepos(ctx, q, ownerUsername, viewerID) |
| 136 |
|
} |
| 137 |
|
|
| 138 |
|
// ListReposForViewer lists every repository viewer is allowed to see listed, |
| 139 |
|
// across all owners, newest first. It answers the instance-wide question — "what |
| 140 |
|
// may this caller be shown?" — that neither sibling can: ListReposByOwner asks |
| 141 |
|
// the same listing question but only about one owner's repositories, and |
| 142 |
|
// ListReposForDashboard asks a narrower question (what does this user own or |
| 143 |
|
// hold an ACL on) that omits every PUBLIC repository belonging to someone else. |
| 144 |
|
// Consumers that must not silently understate the instance — a cross-owner |
| 145 |
|
// listing tool, the cross-database ready page — need this one. |
| 146 |
|
// |
| 147 |
|
// The listing rule is exactly ListReposByOwner's, minus the owner filter: |
| 148 |
|
// |
| 149 |
|
// - The owner, and any user holding an ACL entry on a repo, see it regardless |
| 150 |
|
// of visibility (including PRIVATE and UNLISTED). |
| 151 |
|
// - Everyone else sees only PUBLIC repositories. UNLISTED ones stay reachable |
| 152 |
|
// by direct address — that is a browse question, not a listing one — but are |
| 153 |
|
// never listed to a stranger, and PRIVATE ones are never listed either. |
| 154 |
|
// |
| 155 |
|
// Anonymous (viewer == nil) is an ordinary caller here, not an error: it gets |
| 156 |
|
// the PUBLIC set. An instance with nothing public yields an empty result and no |
| 157 |
|
// error. |
| 158 |
8 |
func (s *Store) ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) { |
| 159 |
8 |
// Anonymous is spelled as user id 0, which no mirrored user row can carry |
| 160 |
8 |
// (ids come from meta and are positive), so both identity branches below are |
| 161 |
8 |
// simply false for it and only the PUBLIC branch can match. |
| 162 |
8 |
viewerID := 0 |
| 163 |
8 |
if viewer != nil { |
| 164 |
6 |
viewerID = viewer.UserID |
| 165 |
6 |
} |
| 166 |
8 |
q := repoSelect + ` |
| 167 |
8 |
WHERE r.visibility = 'PUBLIC' |
| 168 |
8 |
OR r.owner_id = $1 |
| 169 |
8 |
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1) |
| 170 |
8 |
ORDER BY r.created DESC, r.id DESC` |
| 171 |
8 |
return s.queryRepos(ctx, q, viewerID) |
| 172 |
|
} |
| 173 |
|
|
| 174 |
|
// ListReposForDashboard lists every repository the given user owns or holds an |
| 175 |
|
// ACL entry on, newest first. Used for the signed-in user's dashboard. |
| 176 |
1 |
func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) { |
| 177 |
1 |
q := repoSelect + ` |
| 178 |
1 |
WHERE r.owner_id = $1 |
| 179 |
1 |
OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1) |
| 180 |
1 |
ORDER BY r.created DESC, r.id DESC` |
| 181 |
1 |
return s.queryRepos(ctx, q, userID) |
| 182 |
1 |
} |
| 183 |
|
|
| 184 |
14 |
func (s *Store) queryRepos(ctx context.Context, q string, args ...any) ([]*core.Repo, error) { |
| 185 |
14 |
rows, err := s.q.QueryContext(ctx, q, args...) |
| 186 |
14 |
if err != nil { |
| 187 |
0 |
return nil, fmt.Errorf("list repos: %w", err) |
| 188 |
0 |
} |
| 189 |
14 |
defer rows.Close() |
| 190 |
14 |
var repos []*core.Repo |
| 191 |
28 |
for rows.Next() { |
| 192 |
28 |
repo, err := scanRepo(rows) |
| 193 |
28 |
if err != nil { |
| 194 |
0 |
return nil, fmt.Errorf("scan repo: %w", err) |
| 195 |
0 |
} |
| 196 |
28 |
repos = append(repos, repo) |
| 197 |
|
} |
| 198 |
14 |
if err := rows.Err(); err != nil { |
| 199 |
0 |
return nil, fmt.Errorf("iterate repos: %w", err) |
| 200 |
0 |
} |
| 201 |
14 |
return repos, nil |
| 202 |
|
} |
| 203 |
|
|
| 204 |
|
// UpdateRepo updates the mutable repository fields (description and visibility) |
| 205 |
|
// and bumps updated. Returns ErrNotFound if id does not exist. |
| 206 |
3 |
func (s *Store) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error { |
| 207 |
3 |
const q = ` |
| 208 |
3 |
UPDATE repository |
| 209 |
3 |
SET description = $2, visibility = $3, updated = $4 |
| 210 |
3 |
WHERE id = $1` |
| 211 |
3 |
var desc any |
| 212 |
3 |
if description != "" { |
| 213 |
3 |
desc = description |
| 214 |
3 |
} |
| 215 |
3 |
res, err := s.q.ExecContext(ctx, q, id, desc, string(visibility), time.Now().UTC()) |
| 216 |
3 |
if err != nil { |
| 217 |
0 |
return fmt.Errorf("update repo %d: %w", id, err) |
| 218 |
0 |
} |
| 219 |
3 |
return requireOne(res, "update repo") |
| 220 |
|
} |
| 221 |
|
|
| 222 |
|
// RenameRepo moves a repository to a new name and on-disk path in one |
| 223 |
|
// statement, and bumps updated. Both columns move together on purpose: path is |
| 224 |
|
// derived from (owner, name) by storage.RepoDiskPath, and a row whose name and |
| 225 |
|
// path disagree would be served from the wrong store. |
| 226 |
|
// |
| 227 |
|
// The caller is responsible for validating name (core.ValidateName) and for |
| 228 |
|
// moving the store on disk; this is the metadata half only. A name already |
| 229 |
|
// taken by the same owner comes back as ErrNameTaken — mapped from both unique |
| 230 |
|
// indexes the way CreateRepo maps them, because a rename to an existing |
| 231 |
|
// database trips exactly the same pair — and a missing id as ErrNotFound. |
| 232 |
6 |
func (s *Store) RenameRepo(ctx context.Context, id int, name, path string) error { |
| 233 |
6 |
const q = ` |
| 234 |
6 |
UPDATE repository |
| 235 |
6 |
SET name = $2, path = $3, updated = $4 |
| 236 |
6 |
WHERE id = $1` |
| 237 |
6 |
res, err := s.q.ExecContext(ctx, q, id, name, path, time.Now().UTC()) |
| 238 |
6 |
if err != nil { |
| 239 |
2 |
var pqErr *pq.Error |
| 240 |
2 |
if errors.As(err, &pqErr) && pqErr.Code == "23505" && |
| 241 |
2 |
(pqErr.Constraint == "uq_repo_owner_id_name" || |
| 242 |
2 |
pqErr.Constraint == "repository_path_key") { |
| 243 |
2 |
return ErrNameTaken |
| 244 |
2 |
} |
| 245 |
0 |
return fmt.Errorf("rename repo %d: %w", id, err) |
| 246 |
|
} |
| 247 |
4 |
return requireOne(res, "rename repo") |
| 248 |
|
} |
| 249 |
|
|
| 250 |
|
// DeleteRepo removes a repository row (cascading to its access entries). The |
| 251 |
|
// on-disk store removal is the caller's responsibility. Returns ErrNotFound if |
| 252 |
|
// id does not exist. |
| 253 |
2 |
func (s *Store) DeleteRepo(ctx context.Context, id int) error { |
| 254 |
2 |
res, err := s.q.ExecContext(ctx, `DELETE FROM repository WHERE id = $1`, id) |
| 255 |
2 |
if err != nil { |
| 256 |
0 |
return fmt.Errorf("delete repo %d: %w", id, err) |
| 257 |
0 |
} |
| 258 |
2 |
return requireOne(res, "delete repo") |
| 259 |
|
} |
| 260 |
|
|
| 261 |
|
// requireOne turns a zero-rows-affected result into ErrNotFound so that missing |
| 262 |
|
// targets surface loudly instead of passing silently. |
| 263 |
15 |
func requireOne(res sql.Result, what string) error { |
| 264 |
15 |
n, err := res.RowsAffected() |
| 265 |
15 |
if err != nil { |
| 266 |
0 |
return fmt.Errorf("%s: rows affected: %w", what, err) |
| 267 |
0 |
} |
| 268 |
15 |
if n == 0 { |
| 269 |
6 |
return ErrNotFound |
| 270 |
6 |
} |
| 271 |
9 |
return nil |
| 272 |
|
} |