markdown · 5747 bytes Raw Blame History

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_decGREATEST(star_count - 1, 0) defends against drift if the count ever underflows.

Read patterns:

  • ListStargazersForRepo(repo_id, limit, offset) — paginated, sorted by starred_at DESC. Excludes suspended users (per the spec pitfall: "suspended users don't taint public lists").
  • ListStarsForUser(user_id, limit, offset) — drives the Stars profile tab. Sorted by starred_at DESC per 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. Inserts level='all' so collabs get full notifications by default.
  • AutoWatchOnInvolvement(userID, repoID) — invoked by issue create, issue comment, PR create, mention resolution, and review request. Inserts level='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 the Can step 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.

  • S29 (notifications): consume domain_events for fan-out. The watches rows route per-repo events; the notification_threads table (introduced in S29) routes per-thread subscriptions.
  • S33 (webhooks): consume domain_events for delivery. Same table, different consumer; the id column is the cursor for both.
  • S32 (settings UI): wire AutoWatchOnCollab into 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.