tenseleyflow/shithub / 87c9ed8

Browse files

S26: socialdb sqlc bucket; extend repos SELECTs with star/watcher counts

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
87c9ed88aebe5c5ded1c2eac6b667688446553fc
Parents
ad194f3
Tree
9387e4e

13 changed files

StatusFile+-
M internal/repos/queries/repos.sql 8 4
M internal/repos/sqlc/models.go 70 0
M internal/repos/sqlc/repos.sql.go 16 4
A internal/social/queries/events.sql 32 0
A internal/social/queries/stars.sql 56 0
A internal/social/queries/watches.sql 55 0
A internal/social/sqlc/db.go 25 0
A internal/social/sqlc/events.sql.go 155 0
C internal/social/sqlc/models.go 0 0
A internal/social/sqlc/querier.go 66 0
A internal/social/sqlc/stars.sql.go 210 0
A internal/social/sqlc/watches.sql.go 196 0
M sqlc.yaml 16 0
internal/repos/queries/repos.sqlmodified
@@ -11,14 +11,16 @@ RETURNING id, owner_user_id, owner_org_id, name, description, visibility,
11
           default_branch, is_archived, archived_at, deleted_at,
11
           default_branch, is_archived, archived_at, deleted_at,
12
           disk_used_bytes, fork_of_repo_id, license_key, primary_language,
12
           disk_used_bytes, fork_of_repo_id, license_key, primary_language,
13
           has_issues, has_pulls, created_at, updated_at, default_branch_oid,
13
           has_issues, has_pulls, created_at, updated_at, default_branch_oid,
14
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method;
14
+          allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
15
+          star_count, watcher_count;
15
 
16
 
16
 -- name: GetRepoByID :one
17
 -- name: GetRepoByID :one
17
 SELECT id, owner_user_id, owner_org_id, name, description, visibility,
18
 SELECT id, owner_user_id, owner_org_id, name, description, visibility,
18
        default_branch, is_archived, archived_at, deleted_at,
19
        default_branch, is_archived, archived_at, deleted_at,
19
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
20
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
20
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
21
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
21
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
22
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
23
+       star_count, watcher_count
22
 FROM repos
24
 FROM repos
23
 WHERE id = $1;
25
 WHERE id = $1;
24
 
26
 
@@ -36,7 +38,8 @@ SELECT id, owner_user_id, owner_org_id, name, description, visibility,
36
        default_branch, is_archived, archived_at, deleted_at,
38
        default_branch, is_archived, archived_at, deleted_at,
37
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
39
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
38
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
40
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
39
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
41
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
42
+       star_count, watcher_count
40
 FROM repos
43
 FROM repos
41
 WHERE owner_user_id = $1 AND name = $2 AND deleted_at IS NULL;
44
 WHERE owner_user_id = $1 AND name = $2 AND deleted_at IS NULL;
42
 
45
 
@@ -51,7 +54,8 @@ SELECT id, owner_user_id, owner_org_id, name, description, visibility,
51
        default_branch, is_archived, archived_at, deleted_at,
54
        default_branch, is_archived, archived_at, deleted_at,
52
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
55
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
53
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
56
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
54
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
57
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
58
+       star_count, watcher_count
55
 FROM repos
59
 FROM repos
56
 WHERE owner_user_id = $1 AND deleted_at IS NULL
60
 WHERE owner_user_id = $1 AND deleted_at IS NULL
57
 ORDER BY updated_at DESC;
61
 ORDER BY updated_at DESC;
internal/repos/sqlc/models.gomodified
@@ -709,6 +709,49 @@ func (ns NullTransferStatus) Value() (driver.Value, error) {
709
 	return string(ns.TransferStatus), nil
709
 	return string(ns.TransferStatus), nil
710
 }
710
 }
711
 
711
 
712
+type WatchLevel string
713
+
714
+const (
715
+	WatchLevelAll           WatchLevel = "all"
716
+	WatchLevelParticipating WatchLevel = "participating"
717
+	WatchLevelIgnore        WatchLevel = "ignore"
718
+)
719
+
720
+func (e *WatchLevel) Scan(src interface{}) error {
721
+	switch s := src.(type) {
722
+	case []byte:
723
+		*e = WatchLevel(s)
724
+	case string:
725
+		*e = WatchLevel(s)
726
+	default:
727
+		return fmt.Errorf("unsupported scan type for WatchLevel: %T", src)
728
+	}
729
+	return nil
730
+}
731
+
732
+type NullWatchLevel struct {
733
+	WatchLevel WatchLevel
734
+	Valid      bool // Valid is true if WatchLevel is not NULL
735
+}
736
+
737
+// Scan implements the Scanner interface.
738
+func (ns *NullWatchLevel) Scan(value interface{}) error {
739
+	if value == nil {
740
+		ns.WatchLevel, ns.Valid = "", false
741
+		return nil
742
+	}
743
+	ns.Valid = true
744
+	return ns.WatchLevel.Scan(value)
745
+}
746
+
747
+// Value implements the driver Valuer interface.
748
+func (ns NullWatchLevel) Value() (driver.Value, error) {
749
+	if !ns.Valid {
750
+		return nil, nil
751
+	}
752
+	return string(ns.WatchLevel), nil
753
+}
754
+
712
 type AuthAuditLog struct {
755
 type AuthAuditLog struct {
713
 	ID         int64
756
 	ID         int64
714
 	ActorID    pgtype.Int8
757
 	ActorID    pgtype.Int8
@@ -774,6 +817,18 @@ type CheckSuite struct {
774
 	UpdatedAt  pgtype.Timestamptz
817
 	UpdatedAt  pgtype.Timestamptz
775
 }
818
 }
776
 
819
 
820
+type DomainEvent struct {
821
+	ID          int64
822
+	ActorUserID pgtype.Int8
823
+	Kind        string
824
+	RepoID      pgtype.Int8
825
+	SourceKind  string
826
+	SourceID    int64
827
+	Public      bool
828
+	Payload     []byte
829
+	CreatedAt   pgtype.Timestamptz
830
+}
831
+
777
 type EmailVerification struct {
832
 type EmailVerification struct {
778
 	ID          int64
833
 	ID          int64
779
 	UserEmailID int64
834
 	UserEmailID int64
@@ -1026,6 +1081,8 @@ type Repo struct {
1026
 	AllowRebaseMerge   bool
1081
 	AllowRebaseMerge   bool
1027
 	AllowMergeCommit   bool
1082
 	AllowMergeCommit   bool
1028
 	DefaultMergeMethod PrMergeMethod
1083
 	DefaultMergeMethod PrMergeMethod
1084
+	StarCount          int64
1085
+	WatcherCount       int64
1029
 }
1086
 }
1030
 
1087
 
1031
 type RepoCollaborator struct {
1088
 type RepoCollaborator struct {
@@ -1064,6 +1121,12 @@ type RepoTransferRequest struct {
1064
 	CanceledAt      pgtype.Timestamptz
1121
 	CanceledAt      pgtype.Timestamptz
1065
 }
1122
 }
1066
 
1123
 
1124
+type Star struct {
1125
+	UserID    int64
1126
+	RepoID    int64
1127
+	StarredAt pgtype.Timestamptz
1128
+}
1129
+
1067
 type User struct {
1130
 type User struct {
1068
 	ID                int64
1131
 	ID                int64
1069
 	Username          string
1132
 	Username          string
@@ -1161,6 +1224,13 @@ type UsernameRedirect struct {
1161
 	ChangedAt   pgtype.Timestamptz
1224
 	ChangedAt   pgtype.Timestamptz
1162
 }
1225
 }
1163
 
1226
 
1227
+type Watch struct {
1228
+	UserID    int64
1229
+	RepoID    int64
1230
+	Level     WatchLevel
1231
+	UpdatedAt pgtype.Timestamptz
1232
+}
1233
+
1164
 type WebhookEventsPending struct {
1234
 type WebhookEventsPending struct {
1165
 	ID        int64
1235
 	ID        int64
1166
 	RepoID    int64
1236
 	RepoID    int64
internal/repos/sqlc/repos.sql.gomodified
@@ -35,7 +35,8 @@ RETURNING id, owner_user_id, owner_org_id, name, description, visibility,
35
           default_branch, is_archived, archived_at, deleted_at,
35
           default_branch, is_archived, archived_at, deleted_at,
36
           disk_used_bytes, fork_of_repo_id, license_key, primary_language,
36
           disk_used_bytes, fork_of_repo_id, license_key, primary_language,
37
           has_issues, has_pulls, created_at, updated_at, default_branch_oid,
37
           has_issues, has_pulls, created_at, updated_at, default_branch_oid,
38
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
38
+          allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
39
+          star_count, watcher_count
39
 `
40
 `
40
 
41
 
41
 type CreateRepoParams struct {
42
 type CreateRepoParams struct {
@@ -86,6 +87,8 @@ func (q *Queries) CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams)
86
 		&i.AllowRebaseMerge,
87
 		&i.AllowRebaseMerge,
87
 		&i.AllowMergeCommit,
88
 		&i.AllowMergeCommit,
88
 		&i.DefaultMergeMethod,
89
 		&i.DefaultMergeMethod,
90
+		&i.StarCount,
91
+		&i.WatcherCount,
89
 	)
92
 	)
90
 	return i, err
93
 	return i, err
91
 }
94
 }
@@ -114,7 +117,8 @@ SELECT id, owner_user_id, owner_org_id, name, description, visibility,
114
        default_branch, is_archived, archived_at, deleted_at,
117
        default_branch, is_archived, archived_at, deleted_at,
115
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
118
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
116
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
119
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
117
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
120
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
121
+       star_count, watcher_count
118
 FROM repos
122
 FROM repos
119
 WHERE id = $1
123
 WHERE id = $1
120
 `
124
 `
@@ -146,6 +150,8 @@ func (q *Queries) GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, err
146
 		&i.AllowRebaseMerge,
150
 		&i.AllowRebaseMerge,
147
 		&i.AllowMergeCommit,
151
 		&i.AllowMergeCommit,
148
 		&i.DefaultMergeMethod,
152
 		&i.DefaultMergeMethod,
153
+		&i.StarCount,
154
+		&i.WatcherCount,
149
 	)
155
 	)
150
 	return i, err
156
 	return i, err
151
 }
157
 }
@@ -155,7 +161,8 @@ SELECT id, owner_user_id, owner_org_id, name, description, visibility,
155
        default_branch, is_archived, archived_at, deleted_at,
161
        default_branch, is_archived, archived_at, deleted_at,
156
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
162
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
157
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
163
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
158
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
164
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
165
+       star_count, watcher_count
159
 FROM repos
166
 FROM repos
160
 WHERE owner_user_id = $1 AND name = $2 AND deleted_at IS NULL
167
 WHERE owner_user_id = $1 AND name = $2 AND deleted_at IS NULL
161
 `
168
 `
@@ -192,6 +199,8 @@ func (q *Queries) GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg Ge
192
 		&i.AllowRebaseMerge,
199
 		&i.AllowRebaseMerge,
193
 		&i.AllowMergeCommit,
200
 		&i.AllowMergeCommit,
194
 		&i.DefaultMergeMethod,
201
 		&i.DefaultMergeMethod,
202
+		&i.StarCount,
203
+		&i.WatcherCount,
195
 	)
204
 	)
196
 	return i, err
205
 	return i, err
197
 }
206
 }
@@ -262,7 +271,8 @@ SELECT id, owner_user_id, owner_org_id, name, description, visibility,
262
        default_branch, is_archived, archived_at, deleted_at,
271
        default_branch, is_archived, archived_at, deleted_at,
263
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
272
        disk_used_bytes, fork_of_repo_id, license_key, primary_language,
264
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
273
        has_issues, has_pulls, created_at, updated_at, default_branch_oid,
265
-       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method
274
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
275
+       star_count, watcher_count
266
 FROM repos
276
 FROM repos
267
 WHERE owner_user_id = $1 AND deleted_at IS NULL
277
 WHERE owner_user_id = $1 AND deleted_at IS NULL
268
 ORDER BY updated_at DESC
278
 ORDER BY updated_at DESC
@@ -301,6 +311,8 @@ func (q *Queries) ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserI
301
 			&i.AllowRebaseMerge,
311
 			&i.AllowRebaseMerge,
302
 			&i.AllowMergeCommit,
312
 			&i.AllowMergeCommit,
303
 			&i.DefaultMergeMethod,
313
 			&i.DefaultMergeMethod,
314
+			&i.StarCount,
315
+			&i.WatcherCount,
304
 		); err != nil {
316
 		); err != nil {
305
 			return nil, err
317
 			return nil, err
306
 		}
318
 		}
internal/social/queries/events.sqladded
@@ -0,0 +1,32 @@
1
+-- ─── domain_events ─────────────────────────────────────────────────
2
+
3
+-- name: InsertDomainEvent :one
4
+-- Returns the inserted row so callers that want to fire a follow-on
5
+-- (e.g. NOTIFY for the worker pool) have the id without a re-read.
6
+INSERT INTO domain_events (
7
+    actor_user_id, kind, repo_id, source_kind, source_id, public, payload
8
+) VALUES (
9
+    $1, $2, $3, $4, $5, $6, $7
10
+)
11
+RETURNING id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at;
12
+
13
+-- name: ListPublicEventsForActor :many
14
+-- Public activity-feed slice for a user's profile. Returns only
15
+-- public rows, recency-sorted. The handler additionally filters by
16
+-- repo visibility against the viewer (a public event row on a repo
17
+-- whose visibility flipped to private must not leak).
18
+SELECT id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at
19
+FROM domain_events
20
+WHERE actor_user_id = $1
21
+  AND public = true
22
+ORDER BY created_at DESC
23
+LIMIT $2 OFFSET $3;
24
+
25
+-- name: ListEventsForRepo :many
26
+-- Repo-scoped events, recency-sorted. No visibility filter — the
27
+-- caller has already established read access to the repo.
28
+SELECT id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at
29
+FROM domain_events
30
+WHERE repo_id = $1
31
+ORDER BY created_at DESC
32
+LIMIT $2 OFFSET $3;
internal/social/queries/stars.sqladded
@@ -0,0 +1,56 @@
1
+-- ─── stars ─────────────────────────────────────────────────────────
2
+
3
+-- name: InsertStar :exec
4
+-- ON CONFLICT DO NOTHING is the idempotency guard: re-starring an
5
+-- already-starred repo doesn't double-increment the count (the
6
+-- AFTER INSERT trigger only fires on actual insert).
7
+INSERT INTO stars (user_id, repo_id) VALUES ($1, $2)
8
+ON CONFLICT (user_id, repo_id) DO NOTHING;
9
+
10
+-- name: DeleteStar :exec
11
+DELETE FROM stars WHERE user_id = $1 AND repo_id = $2;
12
+
13
+-- name: HasStar :one
14
+SELECT EXISTS (
15
+    SELECT 1 FROM stars WHERE user_id = $1 AND repo_id = $2
16
+) AS has_star;
17
+
18
+-- name: ListStargazersForRepo :many
19
+-- Public-repo stargazer list. Paginated by `starred_at DESC`.
20
+-- Excludes suspended users so they don't taint public lists. The
21
+-- private-repo gate is at the handler layer (policy.IsVisibleTo).
22
+SELECT s.user_id, s.starred_at, u.username, u.display_name
23
+FROM stars s
24
+JOIN users u ON u.id = s.user_id
25
+WHERE s.repo_id = $1
26
+  AND u.suspended_at IS NULL
27
+ORDER BY s.starred_at DESC
28
+LIMIT $2 OFFSET $3;
29
+
30
+-- name: CountStargazersForRepo :one
31
+SELECT COUNT(*) FROM stars s
32
+JOIN users u ON u.id = s.user_id
33
+WHERE s.repo_id = $1
34
+  AND u.suspended_at IS NULL;
35
+
36
+-- name: ListStarsForUser :many
37
+-- The "Stars" profile tab. The handler layer post-filters for repo
38
+-- visibility against the viewer; this query returns everything the
39
+-- user starred and lets the handler decide what to render. Sort axis
40
+-- is the spec's day-1 lean: most-recently-starred first.
41
+SELECT s.repo_id, s.starred_at,
42
+       r.name AS repo_name, r.description, r.visibility,
43
+       r.star_count, r.primary_language, r.updated_at,
44
+       r.owner_user_id, r.owner_org_id
45
+FROM stars s
46
+JOIN repos r ON r.id = s.repo_id
47
+WHERE s.user_id = $1
48
+  AND r.deleted_at IS NULL
49
+ORDER BY s.starred_at DESC
50
+LIMIT $2 OFFSET $3;
51
+
52
+-- name: CountStarsForUser :one
53
+SELECT COUNT(*) FROM stars s
54
+JOIN repos r ON r.id = s.repo_id
55
+WHERE s.user_id = $1
56
+  AND r.deleted_at IS NULL;
internal/social/queries/watches.sqladded
@@ -0,0 +1,55 @@
1
+-- ─── watches ───────────────────────────────────────────────────────
2
+
3
+-- name: GetWatch :one
4
+SELECT user_id, repo_id, level, updated_at
5
+FROM watches WHERE user_id = $1 AND repo_id = $2;
6
+
7
+-- name: UpsertWatch :exec
8
+-- Always-write upsert. The AFTER trigger handles the watcher_count
9
+-- delta on transition into / out of `ignore`.
10
+INSERT INTO watches (user_id, repo_id, level)
11
+VALUES ($1, $2, $3)
12
+ON CONFLICT (user_id, repo_id) DO UPDATE
13
+    SET level = EXCLUDED.level,
14
+        updated_at = now();
15
+
16
+-- name: InsertWatchIfAbsent :exec
17
+-- Auto-watch flow: only insert if the user doesn't already have a
18
+-- preference. ON CONFLICT DO NOTHING preserves the user's chosen
19
+-- level when the trigger fires repeatedly.
20
+INSERT INTO watches (user_id, repo_id, level)
21
+VALUES ($1, $2, $3)
22
+ON CONFLICT (user_id, repo_id) DO NOTHING;
23
+
24
+-- name: DeleteWatch :exec
25
+-- Used when a user unsets their explicit preference (returning to the
26
+-- implicit `participating` default). Trigger drops the watcher_count
27
+-- when the prior level wasn't 'ignore'.
28
+DELETE FROM watches WHERE user_id = $1 AND repo_id = $2;
29
+
30
+-- name: ListWatchersForRepo :many
31
+-- Watchers list. `level <> 'ignore'` excludes users who have actively
32
+-- muted the repo. Excludes suspended users from public surfaces.
33
+SELECT w.user_id, w.level, w.updated_at, u.username, u.display_name
34
+FROM watches w
35
+JOIN users u ON u.id = w.user_id
36
+WHERE w.repo_id = $1
37
+  AND w.level <> 'ignore'
38
+  AND u.suspended_at IS NULL
39
+ORDER BY w.updated_at DESC
40
+LIMIT $2 OFFSET $3;
41
+
42
+-- name: CountWatchersForRepo :one
43
+SELECT COUNT(*) FROM watches w
44
+JOIN users u ON u.id = w.user_id
45
+WHERE w.repo_id = $1
46
+  AND w.level <> 'ignore'
47
+  AND u.suspended_at IS NULL;
48
+
49
+-- name: ListRepoWatchersByLevel :many
50
+-- S29 notification-routing consumer: for fan-out, get every watcher
51
+-- of a repo at the requested level (e.g. `level='all'` for new-issue
52
+-- events). This is the cross-package read; expose the user_ids
53
+-- without joining users — fan-out adds the user join itself.
54
+SELECT user_id FROM watches
55
+WHERE repo_id = $1 AND level = $2;
internal/social/sqlc/db.goadded
@@ -0,0 +1,25 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+
5
+package socialdb
6
+
7
+import (
8
+	"context"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgconn"
12
+)
13
+
14
+type DBTX interface {
15
+	Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
16
+	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
17
+	QueryRow(context.Context, string, ...interface{}) pgx.Row
18
+}
19
+
20
+func New() *Queries {
21
+	return &Queries{}
22
+}
23
+
24
+type Queries struct {
25
+}
internal/social/sqlc/events.sql.goadded
@@ -0,0 +1,155 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: events.sql
5
+
6
+package socialdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const insertDomainEvent = `-- name: InsertDomainEvent :one
15
+
16
+INSERT INTO domain_events (
17
+    actor_user_id, kind, repo_id, source_kind, source_id, public, payload
18
+) VALUES (
19
+    $1, $2, $3, $4, $5, $6, $7
20
+)
21
+RETURNING id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at
22
+`
23
+
24
+type InsertDomainEventParams struct {
25
+	ActorUserID pgtype.Int8
26
+	Kind        string
27
+	RepoID      pgtype.Int8
28
+	SourceKind  string
29
+	SourceID    int64
30
+	Public      bool
31
+	Payload     []byte
32
+}
33
+
34
+// ─── domain_events ─────────────────────────────────────────────────
35
+// Returns the inserted row so callers that want to fire a follow-on
36
+// (e.g. NOTIFY for the worker pool) have the id without a re-read.
37
+func (q *Queries) InsertDomainEvent(ctx context.Context, db DBTX, arg InsertDomainEventParams) (DomainEvent, error) {
38
+	row := db.QueryRow(ctx, insertDomainEvent,
39
+		arg.ActorUserID,
40
+		arg.Kind,
41
+		arg.RepoID,
42
+		arg.SourceKind,
43
+		arg.SourceID,
44
+		arg.Public,
45
+		arg.Payload,
46
+	)
47
+	var i DomainEvent
48
+	err := row.Scan(
49
+		&i.ID,
50
+		&i.ActorUserID,
51
+		&i.Kind,
52
+		&i.RepoID,
53
+		&i.SourceKind,
54
+		&i.SourceID,
55
+		&i.Public,
56
+		&i.Payload,
57
+		&i.CreatedAt,
58
+	)
59
+	return i, err
60
+}
61
+
62
+const listEventsForRepo = `-- name: ListEventsForRepo :many
63
+SELECT id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at
64
+FROM domain_events
65
+WHERE repo_id = $1
66
+ORDER BY created_at DESC
67
+LIMIT $2 OFFSET $3
68
+`
69
+
70
+type ListEventsForRepoParams struct {
71
+	RepoID pgtype.Int8
72
+	Limit  int32
73
+	Offset int32
74
+}
75
+
76
+// Repo-scoped events, recency-sorted. No visibility filter — the
77
+// caller has already established read access to the repo.
78
+func (q *Queries) ListEventsForRepo(ctx context.Context, db DBTX, arg ListEventsForRepoParams) ([]DomainEvent, error) {
79
+	rows, err := db.Query(ctx, listEventsForRepo, arg.RepoID, arg.Limit, arg.Offset)
80
+	if err != nil {
81
+		return nil, err
82
+	}
83
+	defer rows.Close()
84
+	items := []DomainEvent{}
85
+	for rows.Next() {
86
+		var i DomainEvent
87
+		if err := rows.Scan(
88
+			&i.ID,
89
+			&i.ActorUserID,
90
+			&i.Kind,
91
+			&i.RepoID,
92
+			&i.SourceKind,
93
+			&i.SourceID,
94
+			&i.Public,
95
+			&i.Payload,
96
+			&i.CreatedAt,
97
+		); err != nil {
98
+			return nil, err
99
+		}
100
+		items = append(items, i)
101
+	}
102
+	if err := rows.Err(); err != nil {
103
+		return nil, err
104
+	}
105
+	return items, nil
106
+}
107
+
108
+const listPublicEventsForActor = `-- name: ListPublicEventsForActor :many
109
+SELECT id, actor_user_id, kind, repo_id, source_kind, source_id, public, payload, created_at
110
+FROM domain_events
111
+WHERE actor_user_id = $1
112
+  AND public = true
113
+ORDER BY created_at DESC
114
+LIMIT $2 OFFSET $3
115
+`
116
+
117
+type ListPublicEventsForActorParams struct {
118
+	ActorUserID pgtype.Int8
119
+	Limit       int32
120
+	Offset      int32
121
+}
122
+
123
+// Public activity-feed slice for a user's profile. Returns only
124
+// public rows, recency-sorted. The handler additionally filters by
125
+// repo visibility against the viewer (a public event row on a repo
126
+// whose visibility flipped to private must not leak).
127
+func (q *Queries) ListPublicEventsForActor(ctx context.Context, db DBTX, arg ListPublicEventsForActorParams) ([]DomainEvent, error) {
128
+	rows, err := db.Query(ctx, listPublicEventsForActor, arg.ActorUserID, arg.Limit, arg.Offset)
129
+	if err != nil {
130
+		return nil, err
131
+	}
132
+	defer rows.Close()
133
+	items := []DomainEvent{}
134
+	for rows.Next() {
135
+		var i DomainEvent
136
+		if err := rows.Scan(
137
+			&i.ID,
138
+			&i.ActorUserID,
139
+			&i.Kind,
140
+			&i.RepoID,
141
+			&i.SourceKind,
142
+			&i.SourceID,
143
+			&i.Public,
144
+			&i.Payload,
145
+			&i.CreatedAt,
146
+		); err != nil {
147
+			return nil, err
148
+		}
149
+		items = append(items, i)
150
+	}
151
+	if err := rows.Err(); err != nil {
152
+		return nil, err
153
+	}
154
+	return items, nil
155
+}
internal/repos/sqlc/models.go → internal/social/sqlc/models.gocopied (95% similarity)
@@ -2,7 +2,7 @@
2
 // versions:
2
 // versions:
3
 //   sqlc v1.31.1
3
 //   sqlc v1.31.1
4
 
4
 
5
-package reposdb
5
+package socialdb
6
 
6
 
7
 import (
7
 import (
8
 	"database/sql/driver"
8
 	"database/sql/driver"
@@ -709,6 +709,49 @@ func (ns NullTransferStatus) Value() (driver.Value, error) {
709
 	return string(ns.TransferStatus), nil
709
 	return string(ns.TransferStatus), nil
710
 }
710
 }
711
 
711
 
712
+type WatchLevel string
713
+
714
+const (
715
+	WatchLevelAll           WatchLevel = "all"
716
+	WatchLevelParticipating WatchLevel = "participating"
717
+	WatchLevelIgnore        WatchLevel = "ignore"
718
+)
719
+
720
+func (e *WatchLevel) Scan(src interface{}) error {
721
+	switch s := src.(type) {
722
+	case []byte:
723
+		*e = WatchLevel(s)
724
+	case string:
725
+		*e = WatchLevel(s)
726
+	default:
727
+		return fmt.Errorf("unsupported scan type for WatchLevel: %T", src)
728
+	}
729
+	return nil
730
+}
731
+
732
+type NullWatchLevel struct {
733
+	WatchLevel WatchLevel
734
+	Valid      bool // Valid is true if WatchLevel is not NULL
735
+}
736
+
737
+// Scan implements the Scanner interface.
738
+func (ns *NullWatchLevel) Scan(value interface{}) error {
739
+	if value == nil {
740
+		ns.WatchLevel, ns.Valid = "", false
741
+		return nil
742
+	}
743
+	ns.Valid = true
744
+	return ns.WatchLevel.Scan(value)
745
+}
746
+
747
+// Value implements the driver Valuer interface.
748
+func (ns NullWatchLevel) Value() (driver.Value, error) {
749
+	if !ns.Valid {
750
+		return nil, nil
751
+	}
752
+	return string(ns.WatchLevel), nil
753
+}
754
+
712
 type AuthAuditLog struct {
755
 type AuthAuditLog struct {
713
 	ID         int64
756
 	ID         int64
714
 	ActorID    pgtype.Int8
757
 	ActorID    pgtype.Int8
@@ -774,6 +817,18 @@ type CheckSuite struct {
774
 	UpdatedAt  pgtype.Timestamptz
817
 	UpdatedAt  pgtype.Timestamptz
775
 }
818
 }
776
 
819
 
820
+type DomainEvent struct {
821
+	ID          int64
822
+	ActorUserID pgtype.Int8
823
+	Kind        string
824
+	RepoID      pgtype.Int8
825
+	SourceKind  string
826
+	SourceID    int64
827
+	Public      bool
828
+	Payload     []byte
829
+	CreatedAt   pgtype.Timestamptz
830
+}
831
+
777
 type EmailVerification struct {
832
 type EmailVerification struct {
778
 	ID          int64
833
 	ID          int64
779
 	UserEmailID int64
834
 	UserEmailID int64
@@ -1026,6 +1081,8 @@ type Repo struct {
1026
 	AllowRebaseMerge   bool
1081
 	AllowRebaseMerge   bool
1027
 	AllowMergeCommit   bool
1082
 	AllowMergeCommit   bool
1028
 	DefaultMergeMethod PrMergeMethod
1083
 	DefaultMergeMethod PrMergeMethod
1084
+	StarCount          int64
1085
+	WatcherCount       int64
1029
 }
1086
 }
1030
 
1087
 
1031
 type RepoCollaborator struct {
1088
 type RepoCollaborator struct {
@@ -1064,6 +1121,12 @@ type RepoTransferRequest struct {
1064
 	CanceledAt      pgtype.Timestamptz
1121
 	CanceledAt      pgtype.Timestamptz
1065
 }
1122
 }
1066
 
1123
 
1124
+type Star struct {
1125
+	UserID    int64
1126
+	RepoID    int64
1127
+	StarredAt pgtype.Timestamptz
1128
+}
1129
+
1067
 type User struct {
1130
 type User struct {
1068
 	ID                int64
1131
 	ID                int64
1069
 	Username          string
1132
 	Username          string
@@ -1161,6 +1224,13 @@ type UsernameRedirect struct {
1161
 	ChangedAt   pgtype.Timestamptz
1224
 	ChangedAt   pgtype.Timestamptz
1162
 }
1225
 }
1163
 
1226
 
1227
+type Watch struct {
1228
+	UserID    int64
1229
+	RepoID    int64
1230
+	Level     WatchLevel
1231
+	UpdatedAt pgtype.Timestamptz
1232
+}
1233
+
1164
 type WebhookEventsPending struct {
1234
 type WebhookEventsPending struct {
1165
 	ID        int64
1235
 	ID        int64
1166
 	RepoID    int64
1236
 	RepoID    int64
internal/social/sqlc/querier.goadded
@@ -0,0 +1,66 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+
5
+package socialdb
6
+
7
+import (
8
+	"context"
9
+)
10
+
11
+type Querier interface {
12
+	CountStargazersForRepo(ctx context.Context, db DBTX, repoID int64) (int64, error)
13
+	CountStarsForUser(ctx context.Context, db DBTX, userID int64) (int64, error)
14
+	CountWatchersForRepo(ctx context.Context, db DBTX, repoID int64) (int64, error)
15
+	DeleteStar(ctx context.Context, db DBTX, arg DeleteStarParams) error
16
+	// Used when a user unsets their explicit preference (returning to the
17
+	// implicit `participating` default). Trigger drops the watcher_count
18
+	// when the prior level wasn't 'ignore'.
19
+	DeleteWatch(ctx context.Context, db DBTX, arg DeleteWatchParams) error
20
+	// ─── watches ───────────────────────────────────────────────────────
21
+	GetWatch(ctx context.Context, db DBTX, arg GetWatchParams) (Watch, error)
22
+	HasStar(ctx context.Context, db DBTX, arg HasStarParams) (bool, error)
23
+	// ─── domain_events ─────────────────────────────────────────────────
24
+	// Returns the inserted row so callers that want to fire a follow-on
25
+	// (e.g. NOTIFY for the worker pool) have the id without a re-read.
26
+	InsertDomainEvent(ctx context.Context, db DBTX, arg InsertDomainEventParams) (DomainEvent, error)
27
+	// ─── stars ─────────────────────────────────────────────────────────
28
+	// ON CONFLICT DO NOTHING is the idempotency guard: re-starring an
29
+	// already-starred repo doesn't double-increment the count (the
30
+	// AFTER INSERT trigger only fires on actual insert).
31
+	InsertStar(ctx context.Context, db DBTX, arg InsertStarParams) error
32
+	// Auto-watch flow: only insert if the user doesn't already have a
33
+	// preference. ON CONFLICT DO NOTHING preserves the user's chosen
34
+	// level when the trigger fires repeatedly.
35
+	InsertWatchIfAbsent(ctx context.Context, db DBTX, arg InsertWatchIfAbsentParams) error
36
+	// Repo-scoped events, recency-sorted. No visibility filter — the
37
+	// caller has already established read access to the repo.
38
+	ListEventsForRepo(ctx context.Context, db DBTX, arg ListEventsForRepoParams) ([]DomainEvent, error)
39
+	// Public activity-feed slice for a user's profile. Returns only
40
+	// public rows, recency-sorted. The handler additionally filters by
41
+	// repo visibility against the viewer (a public event row on a repo
42
+	// whose visibility flipped to private must not leak).
43
+	ListPublicEventsForActor(ctx context.Context, db DBTX, arg ListPublicEventsForActorParams) ([]DomainEvent, error)
44
+	// S29 notification-routing consumer: for fan-out, get every watcher
45
+	// of a repo at the requested level (e.g. `level='all'` for new-issue
46
+	// events). This is the cross-package read; expose the user_ids
47
+	// without joining users — fan-out adds the user join itself.
48
+	ListRepoWatchersByLevel(ctx context.Context, db DBTX, arg ListRepoWatchersByLevelParams) ([]int64, error)
49
+	// Public-repo stargazer list. Paginated by `starred_at DESC`.
50
+	// Excludes suspended users so they don't taint public lists. The
51
+	// private-repo gate is at the handler layer (policy.IsVisibleTo).
52
+	ListStargazersForRepo(ctx context.Context, db DBTX, arg ListStargazersForRepoParams) ([]ListStargazersForRepoRow, error)
53
+	// The "Stars" profile tab. The handler layer post-filters for repo
54
+	// visibility against the viewer; this query returns everything the
55
+	// user starred and lets the handler decide what to render. Sort axis
56
+	// is the spec's day-1 lean: most-recently-starred first.
57
+	ListStarsForUser(ctx context.Context, db DBTX, arg ListStarsForUserParams) ([]ListStarsForUserRow, error)
58
+	// Watchers list. `level <> 'ignore'` excludes users who have actively
59
+	// muted the repo. Excludes suspended users from public surfaces.
60
+	ListWatchersForRepo(ctx context.Context, db DBTX, arg ListWatchersForRepoParams) ([]ListWatchersForRepoRow, error)
61
+	// Always-write upsert. The AFTER trigger handles the watcher_count
62
+	// delta on transition into / out of `ignore`.
63
+	UpsertWatch(ctx context.Context, db DBTX, arg UpsertWatchParams) error
64
+}
65
+
66
+var _ Querier = (*Queries)(nil)
internal/social/sqlc/stars.sql.goadded
@@ -0,0 +1,210 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: stars.sql
5
+
6
+package socialdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const countStargazersForRepo = `-- name: CountStargazersForRepo :one
15
+SELECT COUNT(*) FROM stars s
16
+JOIN users u ON u.id = s.user_id
17
+WHERE s.repo_id = $1
18
+  AND u.suspended_at IS NULL
19
+`
20
+
21
+func (q *Queries) CountStargazersForRepo(ctx context.Context, db DBTX, repoID int64) (int64, error) {
22
+	row := db.QueryRow(ctx, countStargazersForRepo, repoID)
23
+	var count int64
24
+	err := row.Scan(&count)
25
+	return count, err
26
+}
27
+
28
+const countStarsForUser = `-- name: CountStarsForUser :one
29
+SELECT COUNT(*) FROM stars s
30
+JOIN repos r ON r.id = s.repo_id
31
+WHERE s.user_id = $1
32
+  AND r.deleted_at IS NULL
33
+`
34
+
35
+func (q *Queries) CountStarsForUser(ctx context.Context, db DBTX, userID int64) (int64, error) {
36
+	row := db.QueryRow(ctx, countStarsForUser, userID)
37
+	var count int64
38
+	err := row.Scan(&count)
39
+	return count, err
40
+}
41
+
42
+const deleteStar = `-- name: DeleteStar :exec
43
+DELETE FROM stars WHERE user_id = $1 AND repo_id = $2
44
+`
45
+
46
+type DeleteStarParams struct {
47
+	UserID int64
48
+	RepoID int64
49
+}
50
+
51
+func (q *Queries) DeleteStar(ctx context.Context, db DBTX, arg DeleteStarParams) error {
52
+	_, err := db.Exec(ctx, deleteStar, arg.UserID, arg.RepoID)
53
+	return err
54
+}
55
+
56
+const hasStar = `-- name: HasStar :one
57
+SELECT EXISTS (
58
+    SELECT 1 FROM stars WHERE user_id = $1 AND repo_id = $2
59
+) AS has_star
60
+`
61
+
62
+type HasStarParams struct {
63
+	UserID int64
64
+	RepoID int64
65
+}
66
+
67
+func (q *Queries) HasStar(ctx context.Context, db DBTX, arg HasStarParams) (bool, error) {
68
+	row := db.QueryRow(ctx, hasStar, arg.UserID, arg.RepoID)
69
+	var has_star bool
70
+	err := row.Scan(&has_star)
71
+	return has_star, err
72
+}
73
+
74
+const insertStar = `-- name: InsertStar :exec
75
+
76
+INSERT INTO stars (user_id, repo_id) VALUES ($1, $2)
77
+ON CONFLICT (user_id, repo_id) DO NOTHING
78
+`
79
+
80
+type InsertStarParams struct {
81
+	UserID int64
82
+	RepoID int64
83
+}
84
+
85
+// ─── stars ─────────────────────────────────────────────────────────
86
+// ON CONFLICT DO NOTHING is the idempotency guard: re-starring an
87
+// already-starred repo doesn't double-increment the count (the
88
+// AFTER INSERT trigger only fires on actual insert).
89
+func (q *Queries) InsertStar(ctx context.Context, db DBTX, arg InsertStarParams) error {
90
+	_, err := db.Exec(ctx, insertStar, arg.UserID, arg.RepoID)
91
+	return err
92
+}
93
+
94
+const listStargazersForRepo = `-- name: ListStargazersForRepo :many
95
+SELECT s.user_id, s.starred_at, u.username, u.display_name
96
+FROM stars s
97
+JOIN users u ON u.id = s.user_id
98
+WHERE s.repo_id = $1
99
+  AND u.suspended_at IS NULL
100
+ORDER BY s.starred_at DESC
101
+LIMIT $2 OFFSET $3
102
+`
103
+
104
+type ListStargazersForRepoParams struct {
105
+	RepoID int64
106
+	Limit  int32
107
+	Offset int32
108
+}
109
+
110
+type ListStargazersForRepoRow struct {
111
+	UserID      int64
112
+	StarredAt   pgtype.Timestamptz
113
+	Username    string
114
+	DisplayName string
115
+}
116
+
117
+// Public-repo stargazer list. Paginated by `starred_at DESC`.
118
+// Excludes suspended users so they don't taint public lists. The
119
+// private-repo gate is at the handler layer (policy.IsVisibleTo).
120
+func (q *Queries) ListStargazersForRepo(ctx context.Context, db DBTX, arg ListStargazersForRepoParams) ([]ListStargazersForRepoRow, error) {
121
+	rows, err := db.Query(ctx, listStargazersForRepo, arg.RepoID, arg.Limit, arg.Offset)
122
+	if err != nil {
123
+		return nil, err
124
+	}
125
+	defer rows.Close()
126
+	items := []ListStargazersForRepoRow{}
127
+	for rows.Next() {
128
+		var i ListStargazersForRepoRow
129
+		if err := rows.Scan(
130
+			&i.UserID,
131
+			&i.StarredAt,
132
+			&i.Username,
133
+			&i.DisplayName,
134
+		); err != nil {
135
+			return nil, err
136
+		}
137
+		items = append(items, i)
138
+	}
139
+	if err := rows.Err(); err != nil {
140
+		return nil, err
141
+	}
142
+	return items, nil
143
+}
144
+
145
+const listStarsForUser = `-- name: ListStarsForUser :many
146
+SELECT s.repo_id, s.starred_at,
147
+       r.name AS repo_name, r.description, r.visibility,
148
+       r.star_count, r.primary_language, r.updated_at,
149
+       r.owner_user_id, r.owner_org_id
150
+FROM stars s
151
+JOIN repos r ON r.id = s.repo_id
152
+WHERE s.user_id = $1
153
+  AND r.deleted_at IS NULL
154
+ORDER BY s.starred_at DESC
155
+LIMIT $2 OFFSET $3
156
+`
157
+
158
+type ListStarsForUserParams struct {
159
+	UserID int64
160
+	Limit  int32
161
+	Offset int32
162
+}
163
+
164
+type ListStarsForUserRow struct {
165
+	RepoID          int64
166
+	StarredAt       pgtype.Timestamptz
167
+	RepoName        string
168
+	Description     string
169
+	Visibility      RepoVisibility
170
+	StarCount       int64
171
+	PrimaryLanguage pgtype.Text
172
+	UpdatedAt       pgtype.Timestamptz
173
+	OwnerUserID     pgtype.Int8
174
+	OwnerOrgID      pgtype.Int8
175
+}
176
+
177
+// The "Stars" profile tab. The handler layer post-filters for repo
178
+// visibility against the viewer; this query returns everything the
179
+// user starred and lets the handler decide what to render. Sort axis
180
+// is the spec's day-1 lean: most-recently-starred first.
181
+func (q *Queries) ListStarsForUser(ctx context.Context, db DBTX, arg ListStarsForUserParams) ([]ListStarsForUserRow, error) {
182
+	rows, err := db.Query(ctx, listStarsForUser, arg.UserID, arg.Limit, arg.Offset)
183
+	if err != nil {
184
+		return nil, err
185
+	}
186
+	defer rows.Close()
187
+	items := []ListStarsForUserRow{}
188
+	for rows.Next() {
189
+		var i ListStarsForUserRow
190
+		if err := rows.Scan(
191
+			&i.RepoID,
192
+			&i.StarredAt,
193
+			&i.RepoName,
194
+			&i.Description,
195
+			&i.Visibility,
196
+			&i.StarCount,
197
+			&i.PrimaryLanguage,
198
+			&i.UpdatedAt,
199
+			&i.OwnerUserID,
200
+			&i.OwnerOrgID,
201
+		); err != nil {
202
+			return nil, err
203
+		}
204
+		items = append(items, i)
205
+	}
206
+	if err := rows.Err(); err != nil {
207
+		return nil, err
208
+	}
209
+	return items, nil
210
+}
internal/social/sqlc/watches.sql.goadded
@@ -0,0 +1,196 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: watches.sql
5
+
6
+package socialdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const countWatchersForRepo = `-- name: CountWatchersForRepo :one
15
+SELECT COUNT(*) FROM watches w
16
+JOIN users u ON u.id = w.user_id
17
+WHERE w.repo_id = $1
18
+  AND w.level <> 'ignore'
19
+  AND u.suspended_at IS NULL
20
+`
21
+
22
+func (q *Queries) CountWatchersForRepo(ctx context.Context, db DBTX, repoID int64) (int64, error) {
23
+	row := db.QueryRow(ctx, countWatchersForRepo, repoID)
24
+	var count int64
25
+	err := row.Scan(&count)
26
+	return count, err
27
+}
28
+
29
+const deleteWatch = `-- name: DeleteWatch :exec
30
+DELETE FROM watches WHERE user_id = $1 AND repo_id = $2
31
+`
32
+
33
+type DeleteWatchParams struct {
34
+	UserID int64
35
+	RepoID int64
36
+}
37
+
38
+// Used when a user unsets their explicit preference (returning to the
39
+// implicit `participating` default). Trigger drops the watcher_count
40
+// when the prior level wasn't 'ignore'.
41
+func (q *Queries) DeleteWatch(ctx context.Context, db DBTX, arg DeleteWatchParams) error {
42
+	_, err := db.Exec(ctx, deleteWatch, arg.UserID, arg.RepoID)
43
+	return err
44
+}
45
+
46
+const getWatch = `-- name: GetWatch :one
47
+
48
+SELECT user_id, repo_id, level, updated_at
49
+FROM watches WHERE user_id = $1 AND repo_id = $2
50
+`
51
+
52
+type GetWatchParams struct {
53
+	UserID int64
54
+	RepoID int64
55
+}
56
+
57
+// ─── watches ───────────────────────────────────────────────────────
58
+func (q *Queries) GetWatch(ctx context.Context, db DBTX, arg GetWatchParams) (Watch, error) {
59
+	row := db.QueryRow(ctx, getWatch, arg.UserID, arg.RepoID)
60
+	var i Watch
61
+	err := row.Scan(
62
+		&i.UserID,
63
+		&i.RepoID,
64
+		&i.Level,
65
+		&i.UpdatedAt,
66
+	)
67
+	return i, err
68
+}
69
+
70
+const insertWatchIfAbsent = `-- name: InsertWatchIfAbsent :exec
71
+INSERT INTO watches (user_id, repo_id, level)
72
+VALUES ($1, $2, $3)
73
+ON CONFLICT (user_id, repo_id) DO NOTHING
74
+`
75
+
76
+type InsertWatchIfAbsentParams struct {
77
+	UserID int64
78
+	RepoID int64
79
+	Level  WatchLevel
80
+}
81
+
82
+// Auto-watch flow: only insert if the user doesn't already have a
83
+// preference. ON CONFLICT DO NOTHING preserves the user's chosen
84
+// level when the trigger fires repeatedly.
85
+func (q *Queries) InsertWatchIfAbsent(ctx context.Context, db DBTX, arg InsertWatchIfAbsentParams) error {
86
+	_, err := db.Exec(ctx, insertWatchIfAbsent, arg.UserID, arg.RepoID, arg.Level)
87
+	return err
88
+}
89
+
90
+const listRepoWatchersByLevel = `-- name: ListRepoWatchersByLevel :many
91
+SELECT user_id FROM watches
92
+WHERE repo_id = $1 AND level = $2
93
+`
94
+
95
+type ListRepoWatchersByLevelParams struct {
96
+	RepoID int64
97
+	Level  WatchLevel
98
+}
99
+
100
+// S29 notification-routing consumer: for fan-out, get every watcher
101
+// of a repo at the requested level (e.g. `level='all'` for new-issue
102
+// events). This is the cross-package read; expose the user_ids
103
+// without joining users — fan-out adds the user join itself.
104
+func (q *Queries) ListRepoWatchersByLevel(ctx context.Context, db DBTX, arg ListRepoWatchersByLevelParams) ([]int64, error) {
105
+	rows, err := db.Query(ctx, listRepoWatchersByLevel, arg.RepoID, arg.Level)
106
+	if err != nil {
107
+		return nil, err
108
+	}
109
+	defer rows.Close()
110
+	items := []int64{}
111
+	for rows.Next() {
112
+		var user_id int64
113
+		if err := rows.Scan(&user_id); err != nil {
114
+			return nil, err
115
+		}
116
+		items = append(items, user_id)
117
+	}
118
+	if err := rows.Err(); err != nil {
119
+		return nil, err
120
+	}
121
+	return items, nil
122
+}
123
+
124
+const listWatchersForRepo = `-- name: ListWatchersForRepo :many
125
+SELECT w.user_id, w.level, w.updated_at, u.username, u.display_name
126
+FROM watches w
127
+JOIN users u ON u.id = w.user_id
128
+WHERE w.repo_id = $1
129
+  AND w.level <> 'ignore'
130
+  AND u.suspended_at IS NULL
131
+ORDER BY w.updated_at DESC
132
+LIMIT $2 OFFSET $3
133
+`
134
+
135
+type ListWatchersForRepoParams struct {
136
+	RepoID int64
137
+	Limit  int32
138
+	Offset int32
139
+}
140
+
141
+type ListWatchersForRepoRow struct {
142
+	UserID      int64
143
+	Level       WatchLevel
144
+	UpdatedAt   pgtype.Timestamptz
145
+	Username    string
146
+	DisplayName string
147
+}
148
+
149
+// Watchers list. `level <> 'ignore'` excludes users who have actively
150
+// muted the repo. Excludes suspended users from public surfaces.
151
+func (q *Queries) ListWatchersForRepo(ctx context.Context, db DBTX, arg ListWatchersForRepoParams) ([]ListWatchersForRepoRow, error) {
152
+	rows, err := db.Query(ctx, listWatchersForRepo, arg.RepoID, arg.Limit, arg.Offset)
153
+	if err != nil {
154
+		return nil, err
155
+	}
156
+	defer rows.Close()
157
+	items := []ListWatchersForRepoRow{}
158
+	for rows.Next() {
159
+		var i ListWatchersForRepoRow
160
+		if err := rows.Scan(
161
+			&i.UserID,
162
+			&i.Level,
163
+			&i.UpdatedAt,
164
+			&i.Username,
165
+			&i.DisplayName,
166
+		); err != nil {
167
+			return nil, err
168
+		}
169
+		items = append(items, i)
170
+	}
171
+	if err := rows.Err(); err != nil {
172
+		return nil, err
173
+	}
174
+	return items, nil
175
+}
176
+
177
+const upsertWatch = `-- name: UpsertWatch :exec
178
+INSERT INTO watches (user_id, repo_id, level)
179
+VALUES ($1, $2, $3)
180
+ON CONFLICT (user_id, repo_id) DO UPDATE
181
+    SET level = EXCLUDED.level,
182
+        updated_at = now()
183
+`
184
+
185
+type UpsertWatchParams struct {
186
+	UserID int64
187
+	RepoID int64
188
+	Level  WatchLevel
189
+}
190
+
191
+// Always-write upsert. The AFTER trigger handles the watcher_count
192
+// delta on transition into / out of `ignore`.
193
+func (q *Queries) UpsertWatch(ctx context.Context, db DBTX, arg UpsertWatchParams) error {
194
+	_, err := db.Exec(ctx, upsertWatch, arg.UserID, arg.RepoID, arg.Level)
195
+	return err
196
+}
sqlc.yamlmodified
@@ -129,3 +129,19 @@ sql:
129
         emit_exact_table_names: false
129
         emit_exact_table_names: false
130
         emit_empty_slices: true
130
         emit_empty_slices: true
131
         emit_methods_with_db_argument: true
131
         emit_methods_with_db_argument: true
132
+
133
+  - engine: postgresql
134
+    schema: internal/migrationsfs/migrations
135
+    queries: internal/social/queries
136
+    gen:
137
+      go:
138
+        package: socialdb
139
+        out: internal/social/sqlc
140
+        sql_package: pgx/v5
141
+        emit_json_tags: false
142
+        emit_pointers_for_null_types: false
143
+        emit_prepared_queries: false
144
+        emit_interface: true
145
+        emit_exact_table_names: false
146
+        emit_empty_slices: true
147
+        emit_methods_with_db_argument: true