coverage~bigbes/sr-ht-doltede3b0bbdb/repos.go

Coverage
87.2% 75/86 statements
Δ
Blob
4c5ef8c
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 }