~bigbes/sourcehut-dolt

ref: 944a35e9ed6b9baa0a59adb06df09085bc62d508 sourcehut-dolt/db/repos.go -rw-r--r-- 6.5 KiB
944a35e9 — Eugene Blikh web: router, handlers, and sourcehut chrome 30 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
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/lib/pq"

	"go.bigb.es/sourcehut-dolt/core"
)

// repoSelect is the common projection used by every repository read. It joins
// "user" to resolve the owner's username (core.Repo.OwnerName). description is
// nullable in the schema, so it is coalesced to the empty string.
const repoSelect = `
SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id,
       COALESCE(u.username, ''), r.path, r.visibility
FROM repository r
JOIN "user" u ON u.id = r.owner_id`

func scanRepo(sc rowScanner) (*core.Repo, error) {
	var (
		r          core.Repo
		visibility string
	)
	if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID,
		&r.OwnerName, &r.Path, &visibility); err != nil {
		return nil, err
	}
	r.Visibility = core.Visibility(visibility)
	return &r, nil
}

// CreateRepo inserts a new repository row. It is normally run on a transaction
// (Store.WithTx) so the caller can create the on-disk NBS store in the same
// unit of work and roll both back on failure. r.Name, r.OwnerID, r.Path and
// r.Visibility must be set; ID, created and updated are assigned here and the
// populated repo is returned (with OwnerName carried through from the input,
// which the caller already knows). A uq_repo_owner_id_name violation is mapped
// to ErrNameTaken; any other unique violation (e.g. duplicate path) is returned
// unwrapped so the caller sees the true cause.
func (s *Store) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) {
	now := time.Now().UTC()
	const q = `
INSERT INTO repository (created, updated, name, description, owner_id, path, visibility)
VALUES ($1, $1, $2, $3, $4, $5, $6)
RETURNING id`
	var desc any
	if r.Description != "" {
		desc = r.Description
	}
	var id int
	err := s.q.QueryRowContext(ctx, q,
		now, r.Name, desc, r.OwnerID, r.Path, string(r.Visibility)).Scan(&id)
	if err != nil {
		var pqErr *pq.Error
		if errors.As(err, &pqErr) && pqErr.Code == "23505" &&
			pqErr.Constraint == "uq_repo_owner_id_name" {
			return nil, ErrNameTaken
		}
		return nil, fmt.Errorf("insert repository: %w", err)
	}
	out := *r
	out.ID = id
	return &out, nil
}

// GetRepoByOwnerAndName resolves a repository by its owner's username and name.
// Returns ErrNotFound if no such repository exists.
func (s *Store) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) {
	q := repoSelect + `
WHERE u.username = $1 AND r.name = $2`
	repo, err := scanRepo(s.q.QueryRowContext(ctx, q, ownerUsername, name))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get repo %s/%s: %w", ownerUsername, name, err)
	}
	return repo, nil
}

// GetRepoByID resolves a repository by its primary key. Returns ErrNotFound if
// no such repository exists.
func (s *Store) GetRepoByID(ctx context.Context, id int) (*core.Repo, error) {
	q := repoSelect + `
WHERE r.id = $1`
	repo, err := scanRepo(s.q.QueryRowContext(ctx, q, id))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get repo %d: %w", id, err)
	}
	return repo, nil
}

// ListReposByOwner lists the repositories owned by ownerUsername that viewer is
// allowed to see, newest first. The listing rule (distinct from clone/browse
// authorization) is:
//
//   - The owner, and any user holding an ACL entry on a repo, always see it
//     regardless of visibility (including PRIVATE and UNLISTED).
//   - Everyone else — including anonymous viewers (viewer == nil) — sees only
//     PUBLIC repositories. UNLISTED repositories are never listed to non-owners
//     without an ACL, and PRIVATE ones are never listed either.
//
// viewer is the browsing principal; pass nil for an anonymous request.
func (s *Store) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) {
	viewerID := 0
	if viewer != nil {
		viewerID = viewer.UserID
	}
	q := repoSelect + `
WHERE u.username = $1 AND (
    r.visibility = 'PUBLIC'
    OR r.owner_id = $2
    OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $2)
)
ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, ownerUsername, viewerID)
}

// ListReposForDashboard lists every repository the given user owns or holds an
// ACL entry on, newest first. Used for the signed-in user's dashboard.
func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {
	q := repoSelect + `
WHERE r.owner_id = $1
   OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1)
ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, userID)
}

func (s *Store) queryRepos(ctx context.Context, q string, args ...any) ([]*core.Repo, error) {
	rows, err := s.q.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list repos: %w", err)
	}
	defer rows.Close()
	var repos []*core.Repo
	for rows.Next() {
		repo, err := scanRepo(rows)
		if err != nil {
			return nil, fmt.Errorf("scan repo: %w", err)
		}
		repos = append(repos, repo)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate repos: %w", err)
	}
	return repos, nil
}

// UpdateRepo updates the mutable repository fields (description and visibility)
// and bumps updated. Returns ErrNotFound if id does not exist.
func (s *Store) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error {
	const q = `
UPDATE repository
SET description = $2, visibility = $3, updated = $4
WHERE id = $1`
	var desc any
	if description != "" {
		desc = description
	}
	res, err := s.q.ExecContext(ctx, q, id, desc, string(visibility), time.Now().UTC())
	if err != nil {
		return fmt.Errorf("update repo %d: %w", id, err)
	}
	return requireOne(res, "update repo")
}

// DeleteRepo removes a repository row (cascading to its access entries). The
// on-disk store removal is the caller's responsibility. Returns ErrNotFound if
// id does not exist.
func (s *Store) DeleteRepo(ctx context.Context, id int) error {
	res, err := s.q.ExecContext(ctx, `DELETE FROM repository WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("delete repo %d: %w", id, err)
	}
	return requireOne(res, "delete repo")
}

// requireOne turns a zero-rows-affected result into ErrNotFound so that missing
// targets surface loudly instead of passing silently.
func requireOne(res sql.Result, what string) error {
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("%s: rows affected: %w", what, err)
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}