@@ -0,0 +1,140 @@ |
| | 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`. Activity feed (post-MVP) |
| | 88 | + surfaces these in 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. profile-edit, post-MVP) → follows the |
| | 92 | + user's profile-visibility setting. |
| | 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 | +* **Activity feed (post-MVP):** read public events sliced by repo |
| | 132 | + or by actor. |
| | 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. |