tenseleyflow/shithub / be2115f

Browse files

S26: docs/internal/stars-watchers.md

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
be2115f2d8daac318cbf1d2867576be0095facc9
Parents
52fae27
Tree
fa79685

1 changed file

StatusFile+-
A docs/internal/stars-watchers.md 140 0
docs/internal/stars-watchers.mdadded
@@ -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.