Social Feed
S42 turns the S26 social primitives into a GitHub-like network surface: follow graph, signed-in Explore/Home feed, public Explore feed, and cached trending rankings.
Follow Graph
follows stores one follower user and exactly one target:
followee_user_idfor user profiles.followee_org_idfor organization profiles.
The schema enforces target XOR, blocks user self-follows, cascades on
deleted users/orgs, and uses partial unique indexes so follow/unfollow is
idempotent. State changes go through internal/social and record audit
rows when an audit recorder is supplied.
Follow actions emit public user-scoped domain_events:
followed_user,source_kind = "user",source_id = target_user_idfollowed_org,source_kind = "org",source_id = target_org_id
The web layer exposes profile/org Follow buttons and follower/following tabs. Suspended actors are rejected by middleware/policy before mutation.
Feeds
Feeds read from domain_events; handlers never hand-roll visibility
logic. Public feeds require domain_events.public = true, non-deleted
actors, non-suspended actors, and a current public repo if the event is
repo-scoped. This second repo visibility check is load-bearing: an event
emitted while a repo was public must not leak after the repo becomes
private.
After sign-in, the default destination is /explore. / remains the
public build/landing page so the top-left shithub brand is always a way
back to the instance/version stamp.
The signed-in /explore feed includes:
- the viewer's own public activity,
- public activity from followed users,
- public activity from repos the viewer watches,
- public activity from repos owned by followed orgs,
- public org-scoped activity for followed orgs.
Anonymous Explore uses the global public feed. Both feeds page with a
keyset cursor over (created_at, id). Full-page requests with a
before cursor still render that cursor window for no-JavaScript
fallbacks; HTMX requests return only the next feed rows plus a replacement
More control, so the browser appends activity in place like GitHub's
dashboard feed.
Event Kinds
Current feed sources include:
repo_createdpushstar/unstarforkedissue_created, comments, close/reopen, assignment eventspr_openedand pull-request comment eventsfollowed_user/followed_org
unstar events remain in domain_events for audit/product history, but
the feed queries suppress them because GitHub does not surface unstars as
activity feed stories.
The kind and source_kind columns remain text. New product surfaces
can add events without a schema migration as long as their payload is
small JSON and the public flag is set conservatively.
Trending
trending_snapshots stores denormalized rankings for day/week/month
windows and two kinds:
reposusers
The trending:compute worker job captures all six snapshots. A job with
an empty payload schedules its next run one hour later; pass
{"schedule_next":false} for a one-off recompute. Explore reads the
latest weekly snapshot and falls back to live computation before the
first worker run.
The repo score weights recent public stars, forks, and unique push actors. The user score weights recent followers plus recent public event activity.
Operational Notes
Seed the recurring job once after deploy:
INSERT INTO jobs (kind, payload) VALUES ('trending:compute', '{}');
SELECT pg_notify('shithub_jobs', '');
The job is safe to re-run. Multiple recurring seeds produce multiple hourly refresh jobs, so operators should keep one scheduled chain per instance unless they intentionally want a shorter effective interval.
View source
| 1 | # Social Feed |
| 2 | |
| 3 | S42 turns the S26 social primitives into a GitHub-like network surface: |
| 4 | follow graph, signed-in Explore/Home feed, public Explore feed, and cached |
| 5 | trending rankings. |
| 6 | |
| 7 | ## Follow Graph |
| 8 | |
| 9 | `follows` stores one follower user and exactly one target: |
| 10 | |
| 11 | - `followee_user_id` for user profiles. |
| 12 | - `followee_org_id` for organization profiles. |
| 13 | |
| 14 | The schema enforces target XOR, blocks user self-follows, cascades on |
| 15 | deleted users/orgs, and uses partial unique indexes so follow/unfollow is |
| 16 | idempotent. State changes go through `internal/social` and record audit |
| 17 | rows when an audit recorder is supplied. |
| 18 | |
| 19 | Follow actions emit public user-scoped `domain_events`: |
| 20 | |
| 21 | - `followed_user`, `source_kind = "user"`, `source_id = target_user_id` |
| 22 | - `followed_org`, `source_kind = "org"`, `source_id = target_org_id` |
| 23 | |
| 24 | The web layer exposes profile/org Follow buttons and follower/following |
| 25 | tabs. Suspended actors are rejected by middleware/policy before mutation. |
| 26 | |
| 27 | ## Feeds |
| 28 | |
| 29 | Feeds read from `domain_events`; handlers never hand-roll visibility |
| 30 | logic. Public feeds require `domain_events.public = true`, non-deleted |
| 31 | actors, non-suspended actors, and a current public repo if the event is |
| 32 | repo-scoped. This second repo visibility check is load-bearing: an event |
| 33 | emitted while a repo was public must not leak after the repo becomes |
| 34 | private. |
| 35 | |
| 36 | After sign-in, the default destination is `/explore`. `/` remains the |
| 37 | public build/landing page so the top-left shithub brand is always a way |
| 38 | back to the instance/version stamp. |
| 39 | |
| 40 | The signed-in `/explore` feed includes: |
| 41 | |
| 42 | - the viewer's own public activity, |
| 43 | - public activity from followed users, |
| 44 | - public activity from repos the viewer watches, |
| 45 | - public activity from repos owned by followed orgs, |
| 46 | - public org-scoped activity for followed orgs. |
| 47 | |
| 48 | Anonymous Explore uses the global public feed. Both feeds page with a |
| 49 | keyset cursor over `(created_at, id)`. Full-page requests with a |
| 50 | `before` cursor still render that cursor window for no-JavaScript |
| 51 | fallbacks; HTMX requests return only the next feed rows plus a replacement |
| 52 | `More` control, so the browser appends activity in place like GitHub's |
| 53 | dashboard feed. |
| 54 | |
| 55 | ## Event Kinds |
| 56 | |
| 57 | Current feed sources include: |
| 58 | |
| 59 | - `repo_created` |
| 60 | - `push` |
| 61 | - `star` / `unstar` |
| 62 | - `forked` |
| 63 | - `issue_created`, comments, close/reopen, assignment events |
| 64 | - `pr_opened` and pull-request comment events |
| 65 | - `followed_user` / `followed_org` |
| 66 | |
| 67 | `unstar` events remain in `domain_events` for audit/product history, but |
| 68 | the feed queries suppress them because GitHub does not surface unstars as |
| 69 | activity feed stories. |
| 70 | |
| 71 | The `kind` and `source_kind` columns remain text. New product surfaces |
| 72 | can add events without a schema migration as long as their payload is |
| 73 | small JSON and the public flag is set conservatively. |
| 74 | |
| 75 | ## Trending |
| 76 | |
| 77 | `trending_snapshots` stores denormalized rankings for day/week/month |
| 78 | windows and two kinds: |
| 79 | |
| 80 | - `repos` |
| 81 | - `users` |
| 82 | |
| 83 | The `trending:compute` worker job captures all six snapshots. A job with |
| 84 | an empty payload schedules its next run one hour later; pass |
| 85 | `{"schedule_next":false}` for a one-off recompute. Explore reads the |
| 86 | latest weekly snapshot and falls back to live computation before the |
| 87 | first worker run. |
| 88 | |
| 89 | The repo score weights recent public stars, forks, and unique push |
| 90 | actors. The user score weights recent followers plus recent public event |
| 91 | activity. |
| 92 | |
| 93 | ## Operational Notes |
| 94 | |
| 95 | Seed the recurring job once after deploy: |
| 96 | |
| 97 | ```sql |
| 98 | INSERT INTO jobs (kind, payload) VALUES ('trending:compute', '{}'); |
| 99 | SELECT pg_notify('shithub_jobs', ''); |
| 100 | ``` |
| 101 | |
| 102 | The job is safe to re-run. Multiple recurring seeds produce multiple |
| 103 | hourly refresh jobs, so operators should keep one scheduled chain per |
| 104 | instance unless they intentionally want a shorter effective interval. |