Stars, watchers, and the events log (S26)
S26 ships three things that look small but underpin a lot of
downstream sprints: the stars table, the watches table, and the
canonical domain_events log that S29 (notifications) and S33
(webhooks) consume.
Stars
stars(user_id, repo_id, starred_at) — PK (user_id, repo_id).
Idempotent insert via INSERT … ON CONFLICT DO NOTHING. Two
AFTER triggers maintain repos.star_count:
stars_count_inc— fires only on actual INSERT (the conflict path is silent), so re-starring an already-starred repo doesn't double-increment.stars_count_dec—GREATEST(star_count - 1, 0)defends against drift if the count ever underflows.
Read patterns:
ListStargazersForRepo(repo_id, limit, offset)— paginated, sorted bystarred_at DESC. Excludes suspended users (per the spec pitfall: "suspended users don't taint public lists").ListStarsForUser(user_id, limit, offset)— drives theStarsprofile tab. Sorted bystarred_at DESCper the spec's day-1 lean. Soft-deleted repos are filtered out.
Watches
watches(user_id, repo_id, level enum('all','participating','ignore'), updated_at) — PK (user_id, repo_id). Absence of a row is the
implicit participating default, matching GitHub. The
CurrentLevel helper resolves missing rows to participating so
callers never need to special-case the absent state.
repos.watcher_count is the count of rows where level <> 'ignore'.
The watches_count trigger handles all four state-transition shapes
(insert non-ignore → +1; update across ignore boundary → ±1; delete
non-ignore → −1). Test coverage in
internal/social/social_test.go::TestSetWatch_TransitionsAcrossIgnore.
Auto-watch
Two non-destructive entry points populate watches rows when none
exists:
AutoWatchOnCollab(userID, repoID)— invoked by S15's collaborator-add path. Insertslevel='all'so collabs get full notifications by default.AutoWatchOnInvolvement(userID, repoID)— invoked by issue create, issue comment, PR create, mention resolution, and review request. Insertslevel='participating'.
Both use INSERT … ON CONFLICT DO NOTHING, so a user who has
explicitly chosen ignore (or any other level) keeps their choice.
This is the spec's "permission revocation cascade" rule applied at
write time: we never overwrite a user's preference, regardless of
the trigger source.
The collaborator-add wiring lands when S32 ships the collaborators
UI; for now AutoWatchOnCollab is callable but unwired.
domain_events
domain_events(id, actor_user_id, kind, repo_id NULL, source_kind, source_id, public bool, payload jsonb, created_at) is the canonical
event log for both notifications fan-out (S29) and webhook delivery
(S33). The S26 spec called for an events table; the S00-S25 audit
flagged that S29 and S33 would each want their own log if we landed
that as a parallel structure. We adopted the unified shape from day
1 to avoid migrating twice.
S26 emits two event kinds today:
kind = "star"(source_kind = "repo",source_id = repo_id,public = repoIsPublic)kind = "unstar"(same shape)
Future sprints add their own kinds (issue_comment_created,
pr_opened, check_failed, …). The kind and source_kind
columns are intentionally text rather than enums: kinds emerge
incrementally; we don't want a migration per kind.
Public-flag policy
- Public-repo events →
public = true. S42's Home and Explore feeds surface these in authenticated and public timelines. - Private-repo events →
public = false. Visible only to repo collaborators via per-row visibility checks at read time. - User-scoped events (e.g. follow events) → public only when the action is safe to expose on the public timeline.
The handler/orchestrator is the source of truth for the public flag
— social.Star reads repo.visibility and passes the bool through.
Always re-check visibility at fan-out time (S29's spec
explicitly calls this out): a repo can flip from public to private
between event-emit and fan-out, and a stale-read public event must
not leak into a public feed after the flip.
Permission lattice
Two new policy actions:
star:create— login-required only (any logged-in user with read access). Suspended actors blocked at theCanstep before this.watch:set— same shape: login-required, no role gate.
Both rely on the visibility short-circuit at step 4 of Can to
deny anonymous access to private-repo stars/watches with the
404-leak-safe code.
Rate limit
100 star/unstar actions per hour per user, scoped via the
throttle.Limit{Scope: "star", Identifier: "user:N"} envelope
shared with comment / PAT / repo-create rate caps. S35 owns the
broader abuse heuristics; this is the floor against trending
manipulation.
Forward links
- S29 (notifications): consume
domain_eventsfor fan-out. Thewatchesrows route per-repo events; thenotification_threadstable (introduced in S29) routes per-thread subscriptions. - S33 (webhooks): consume
domain_eventsfor delivery. Same table, different consumer; theidcolumn is the cursor for both. - S32 (settings UI): wire
AutoWatchOnCollabinto the collaborator-add path. - S42 (social feed): read public events for Home/Explore and cached trending rankings.
What S11 promised but didn't ship
The S11 status block claimed repos.star_count and
repos.watcher_count were "already declared". They were not — the
0017_repos.sql migration doesn't include them. S26 adds them in
0026_stars.sql. Noted here so future archaeology doesn't blame
the migration for "missing" columns referenced in the spec.
View source
| 1 | # Stars, watchers, and the events log (S26) |
| 2 | |
| 3 | S26 ships three things that look small but underpin a lot of |
| 4 | downstream sprints: the `stars` table, the `watches` table, and the |
| 5 | canonical `domain_events` log that S29 (notifications) and S33 |
| 6 | (webhooks) consume. |
| 7 | |
| 8 | ## Stars |
| 9 | |
| 10 | `stars(user_id, repo_id, starred_at)` — PK `(user_id, repo_id)`. |
| 11 | Idempotent insert via `INSERT … ON CONFLICT DO NOTHING`. Two |
| 12 | `AFTER` triggers maintain `repos.star_count`: |
| 13 | |
| 14 | * `stars_count_inc` — fires only on actual INSERT (the conflict |
| 15 | path is silent), so re-starring an already-starred repo doesn't |
| 16 | double-increment. |
| 17 | * `stars_count_dec` — `GREATEST(star_count - 1, 0)` defends |
| 18 | against drift if the count ever underflows. |
| 19 | |
| 20 | Read patterns: |
| 21 | |
| 22 | * `ListStargazersForRepo(repo_id, limit, offset)` — paginated, sorted |
| 23 | by `starred_at DESC`. Excludes suspended users (per the spec |
| 24 | pitfall: "suspended users don't taint public lists"). |
| 25 | * `ListStarsForUser(user_id, limit, offset)` — drives the `Stars` |
| 26 | profile tab. Sorted by `starred_at DESC` per the spec's day-1 lean. |
| 27 | Soft-deleted repos are filtered out. |
| 28 | |
| 29 | ## Watches |
| 30 | |
| 31 | `watches(user_id, repo_id, level enum('all','participating','ignore'), |
| 32 | updated_at)` — PK `(user_id, repo_id)`. **Absence of a row is the |
| 33 | implicit `participating` default**, matching GitHub. The |
| 34 | `CurrentLevel` helper resolves missing rows to `participating` so |
| 35 | callers never need to special-case the absent state. |
| 36 | |
| 37 | `repos.watcher_count` is the count of rows where `level <> 'ignore'`. |
| 38 | The `watches_count` trigger handles all four state-transition shapes |
| 39 | (insert non-ignore → +1; update across ignore boundary → ±1; delete |
| 40 | non-ignore → −1). Test coverage in |
| 41 | `internal/social/social_test.go::TestSetWatch_TransitionsAcrossIgnore`. |
| 42 | |
| 43 | ### Auto-watch |
| 44 | |
| 45 | Two non-destructive entry points populate `watches` rows when none |
| 46 | exists: |
| 47 | |
| 48 | * `AutoWatchOnCollab(userID, repoID)` — invoked by S15's |
| 49 | collaborator-add path. Inserts `level='all'` so collabs get full |
| 50 | notifications by default. |
| 51 | * `AutoWatchOnInvolvement(userID, repoID)` — invoked by issue |
| 52 | create, issue comment, PR create, mention resolution, and review |
| 53 | request. Inserts `level='participating'`. |
| 54 | |
| 55 | Both use `INSERT … ON CONFLICT DO NOTHING`, so a user who has |
| 56 | explicitly chosen `ignore` (or any other level) keeps their choice. |
| 57 | This is the spec's "permission revocation cascade" rule applied at |
| 58 | write time: we never overwrite a user's preference, regardless of |
| 59 | the trigger source. |
| 60 | |
| 61 | The collaborator-add wiring lands when S32 ships the collaborators |
| 62 | UI; for now `AutoWatchOnCollab` is callable but unwired. |
| 63 | |
| 64 | ## domain_events |
| 65 | |
| 66 | `domain_events(id, actor_user_id, kind, repo_id NULL, source_kind, |
| 67 | source_id, public bool, payload jsonb, created_at)` is the canonical |
| 68 | event log for both notifications fan-out (S29) and webhook delivery |
| 69 | (S33). The S26 spec called for an `events` table; the S00-S25 audit |
| 70 | flagged that S29 and S33 would each want their own log if we landed |
| 71 | that as a parallel structure. We adopted the unified shape from day |
| 72 | 1 to avoid migrating twice. |
| 73 | |
| 74 | S26 emits two event kinds today: |
| 75 | |
| 76 | * `kind = "star"` (`source_kind = "repo"`, `source_id = repo_id`, |
| 77 | `public = repoIsPublic`) |
| 78 | * `kind = "unstar"` (same shape) |
| 79 | |
| 80 | Future sprints add their own kinds (`issue_comment_created`, |
| 81 | `pr_opened`, `check_failed`, …). The `kind` and `source_kind` |
| 82 | columns are intentionally `text` rather than enums: kinds emerge |
| 83 | incrementally; we don't want a migration per kind. |
| 84 | |
| 85 | ### Public-flag policy |
| 86 | |
| 87 | * Public-repo events → `public = true`. S42's Home and Explore feeds |
| 88 | surface these in authenticated and public timelines. |
| 89 | * Private-repo events → `public = false`. Visible only to repo |
| 90 | collaborators via per-row visibility checks at read time. |
| 91 | * User-scoped events (e.g. follow events) → public only when the action |
| 92 | is safe to expose on the public timeline. |
| 93 | |
| 94 | The handler/orchestrator is the source of truth for the public flag |
| 95 | — `social.Star` reads `repo.visibility` and passes the bool through. |
| 96 | **Always re-check visibility at fan-out time** (S29's spec |
| 97 | explicitly calls this out): a repo can flip from public to private |
| 98 | between event-emit and fan-out, and a stale-read public event must |
| 99 | not leak into a public feed after the flip. |
| 100 | |
| 101 | ## Permission lattice |
| 102 | |
| 103 | Two new policy actions: |
| 104 | |
| 105 | * `star:create` — login-required only (any logged-in user with read |
| 106 | access). Suspended actors blocked at the `Can` step before this. |
| 107 | * `watch:set` — same shape: login-required, no role gate. |
| 108 | |
| 109 | Both rely on the visibility short-circuit at step 4 of `Can` to |
| 110 | deny anonymous access to private-repo stars/watches with the |
| 111 | 404-leak-safe code. |
| 112 | |
| 113 | ## Rate limit |
| 114 | |
| 115 | 100 star/unstar actions per hour per user, scoped via the |
| 116 | `throttle.Limit{Scope: "star", Identifier: "user:N"}` envelope |
| 117 | shared with comment / PAT / repo-create rate caps. S35 owns the |
| 118 | broader abuse heuristics; this is the floor against trending |
| 119 | manipulation. |
| 120 | |
| 121 | ## Forward links |
| 122 | |
| 123 | * **S29 (notifications):** consume `domain_events` for fan-out. |
| 124 | The `watches` rows route per-repo events; the `notification_threads` |
| 125 | table (introduced in S29) routes per-thread subscriptions. |
| 126 | * **S33 (webhooks):** consume `domain_events` for delivery. Same |
| 127 | table, different consumer; the `id` column is the cursor for |
| 128 | both. |
| 129 | * **S32 (settings UI):** wire `AutoWatchOnCollab` into the |
| 130 | collaborator-add path. |
| 131 | * **S42 (social feed):** read public events for Home/Explore and cached |
| 132 | trending rankings. |
| 133 | |
| 134 | ## What S11 promised but didn't ship |
| 135 | |
| 136 | The S11 status block claimed `repos.star_count` and |
| 137 | `repos.watcher_count` were "already declared". They were not — the |
| 138 | 0017_repos.sql migration doesn't include them. S26 adds them in |
| 139 | 0026_stars.sql. Noted here so future archaeology doesn't blame |
| 140 | the migration for "missing" columns referenced in the spec. |