Feeds and timelines
feedsFollows, blocks and mutes, home timelines that fan out on write and on read, and trending per region, with a feed that has an end.
feedsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
feeds gives your app a follow graph and a home timeline that stays fast at any follower count, plus trending counts per region. The feed is chronological by default and has an end.
People follow, block and mute each other, post and read, whether an author has ten followers or ten million. Reach for it for a social app, a community or an activity stream.
The feed is chronological by default. You opt in to ranking, and every ranked entry carries a reason and a way back to chronological. The feed has an end: no endless scroll, and no new items inserted while a person reads.
At a glance
feeds:
driver: db
items: posts
fanout: { threshold: 10000, batch: 500 }
timeline: { order: chronological }
page: { end: true, pause_after: 200 }
trending: { window: 1h, k: 20, min-count: 25 }import { feeds } from "mainsdk";
await feeds.publish(user, { id: post.id, visibility: "public" });
const home = await feeds.timeline(user, { limit: 20 }); // ends with a caught-up or pause entryCalls
feeds.follow(follower, target)feeds.unfollow(follower, target)feeds.followers(user)feeds.following(user)feeds.counts(user)feeds.requests(user)feeds.accept(user, from)feeds.decline(user, from)feeds.block(user, target)feeds.unblock(user, target)feeds.mute(user, target, { until? })feeds.unmute(user, target)feeds.relation(a, b)feeds.publish(author, { id, visibility })feeds.edit(author, id)feeds.remove(by, id)feeds.timeline(user, { order?, cursor?, continue? })feeds.profile(viewer, author)feeds.ranker(fn)feeds.count(key, { by })feeds.trending({ window?, k? })
Guarantees
Whatever the driver.
- New items never written to timelines in the request; authors see their own items at once
- Edits, deletes and moderation apply on the next read of every timeline
- Chronological by default; a ranked timeline explains each item and can always be switched off
- Every timeline has an end and a pause, and new items arrive as a count, never pushed into the list
- Trending stores counts only, never a person
Drivers
2 drivers. Select one to see its line in main.yaml.
feeds:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-FEED-0010 | Checkmainsdk check, before deploy | A feed with no end: page.end: false, page.pause_after off or above 500, or a client that requests the next page with no user action |
MAIN-FEED-0011 | Checkmainsdk check, before deploy | timeline.order: ranked, or a feeds.ranker, in an app with a children's audience and no chronological-only rule for it |
MAIN-FEED-0012 | Checkmainsdk check, before deploy | trending.min-count below 10, or a trending key built from a labelled field |
MAIN-FEED-0013 | Checkmainsdk check, before deploy | fanout.threshold above 100,000, timeline.max above 5,000 or max-following above 50,000 |
MAIN-FEED-0001 | RuntimeError responses and logs | Follow, request or profile read refused: one side blocks the other (403) |
MAIN-FEED-0002 | RuntimeError responses and logs | A follow or item across tenants (403) |
MAIN-FEED-0003 | RuntimeError responses and logs | The item doesn't exist, is removed, or isn't visible to the viewer (404) |
MAIN-FEED-0004 | RuntimeError responses and logs | Edit or remove by someone who isn't the author and whom access doesn't allow feeds.remove (403) |
MAIN-FEED-0005 | RuntimeError responses and logs | Following more than max-following (422) |
MAIN-FEED-0006 | RuntimeError responses and logs | A fan-out batch failed after its last retry and is in the dead-letter queue; affected timelines are served by merging at read time (logged) |
MAIN-FEED-0007 | RuntimeError responses and logs | The ranker threw, returned an entry without a reason, or passed ranked.budget; the chronological timeline was served (logged) |
MAIN-FEED-0008 | RuntimeError responses and logs | A pushed value loosens page.end, page.pause_after, trending.min-count or max-following; refused, last good kept |
MAIN-FEED-0009 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| feeds@1 contract | Phase 1 | 0.4 | feeds#contract |
| Follow graph with requests and counts | Phase 1 | 0.4 | feeds#graph |
| Block and mute | Phase 1 | 0.4 | feeds#block-mute |
| Fan-out on write | Phase 1 | 0.4 | feeds#fanout-write |
| Fan-out on read above a threshold and read-your-writes | Phase 1 | 0.4 | feeds#fanout-read |
| Edits and deletes shown at once | Phase 1 | 0.4 | feeds#edits-deletes |
| Chronological and ranked timelines | Phase 1 | 0.4 | feeds#order |
| Caught-up end, pause and children's rules | Phase 1 | 0.4 | feeds#ethics |
| Trending per region | Phase 1 | 0.4 | feeds#trending |
| Tenants and regions | Phase 1 | 0.4 | feeds#tenancy-regions |
| Erasure | Phase 1 | 0.4 | feeds#erase |
| Feed checks | Phase 1 | 0.4 | feeds#checks |
Quick start
feeds:
driver: db
items: postsimport { feeds } from "mainsdk";
declare const user: { id: string };
declare const post: { id: string };
await feeds.publish(user, { id: post.id, visibility: "public" });
const home = await feeds.timeline(user, { limit: 20 }); // ends with a caught-up or pause entryfeeds stores references to your records, not copies. items names the db model whose records are the feed items.
Configuration
feeds:
driver: db # db (default) | local for tests
items: posts # the db model whose records are feed items
fanout:
threshold: 10000 # authors with more followers are merged at read time
batch: 500 # timeline writes per job
max-following: 5000
timeline:
order: chronological # chronological (default) | ranked
max: 800 # entries kept per timeline
keep: 30d
page:
limit: 20 # default page size; at most 100
end: true # a caught-up marker where the last session ended
pause_after: 200 # items per session before a pause marker; 50 for children
ranked:
candidates: 500 # entries ranked per request
budget: 50ms # ranker time per page; over it, chronological is served
trending:
window: 1h
k: 20
min-count: 25 # keys counted for fewer people are hidden; may only be raised
realtime: true # tell viewers how many new items waitWhat you get
A follow graph with blocks and mutes, home timelines that fan out on write (merged at read time for large authors), profiles and trending keys. Every call below takes the acting user as its first argument.
Calls
Follow graph
feeds.follow(follower, target) // { state: "following" | "requested" }; private accounts get a request
feeds.unfollow(follower, target)
feeds.followers(user, o?) // a page of accounts, newest first
feeds.following(user, o?)
feeds.counts(user) // { followers, following }
feeds.requests(user, o?) // pending follow requests
feeds.accept(user, from)
feeds.decline(user, from)
feeds.relation(a, b) // { following, followedBy, requested, blocking, blockedBy, muting }Following yourself, or following twice, returns the current state and changes nothing. Following more than max-following fails with MAIN-FEED-0005.
Block and mute
feeds.block(user, target)
feeds.unblock(user, target)
feeds.mute(user, target, { until? }) // hides the target from the muter's timeline only
feeds.unmute(user, target)A block deletes follows and requests both ways, refuses new ones with MAIN-FEED-0001, and hides each side's items from the other. A mute leaves follows alone. Neither tells the other person.
Items and timelines
feeds.publish(author, { id, visibility }) // visibility: "public" | "followers"
feeds.edit(author, id) // bumps the version after the item record changed
feeds.remove(by, id, { reason? }) // the author, or a subject that access allows
feeds.timeline(user, { order?, limit?, cursor?, continue? })
feeds.profile(viewer, author, o?) // one author's items, as the viewer may see them
feeds.ranker(fn) // register the ranking functionfeeds.publish resolves once the record reference and the fan-out job are accepted, and writes no follower's timeline in the request. An author sees their own new item at the top of their chronological timeline on the next read.
A timeline entry is one of three kinds:
| Kind | Meaning |
|---|---|
item | A feed item. A ranked entry also carries a reason. |
caught-up | The person has seen everything after this point. |
pause | pause_after was reached. There is no next until a request with continue: true. |
order: "chronological" is always accepted, so a person can leave ranking at any time. For a children's audience the timeline is always chronological and pauses after at most 50 items.
Trending
feeds.count(key, { by }) // one occurrence of a key such as "tag:launch"
feeds.trending({ window?, k? }) // { region, window, keys: [{ key, count }] }feeds.count is synchronous and in memory, and a job flushes it to db each minute. Each person counts once per key per window, using a salted hash that rotates every window. No person id is stored. Keys counted for fewer than min-count people are hidden, and regions are never merged.
Examples
Rank with a reason
import { feeds } from "mainsdk";
declare function affinity(user: { id: string }, item: { id: string }): number;
feeds.ranker((candidates, { user }) =>
candidates.map((c) => ({ id: c.id, score: affinity(user, c), reason: "Because you follow this author" })),
);A ranker that throws, omits a reason or passes ranked.budget makes feeds.timeline serve the chronological timeline and log MAIN-FEED-0007.
Show new-item counts
With realtime: true, each fan-out batch publishes the event feeds.new with { count } on user:<id>. It never carries an item. Show "3 new posts" and let the person ask for them.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-FEED-0001 | One side blocks the other (403) | Nothing; the block is the point |
MAIN-FEED-0002 | A follow or item across tenants (403) | Keep both in one tenant |
MAIN-FEED-0003 | The item is missing, removed or not visible to the viewer (404) | Check the id and visibility |
MAIN-FEED-0004 | Edit or remove by someone who is not the author and not allowed by access (403) | Allow feeds.remove for moderators |
MAIN-FEED-0005 | Following more than max-following (422) | Raise the limit, within the check ceiling |
MAIN-FEED-0010 | A feed with no end, or a client that requests the next page with no user action | Keep page.end on and wait for a person |
MAIN-FEED-0011 | Ranking in an app with a children's audience | Serve chronological only |
MAIN-FEED-0012 | trending.min-count below 10, or a key built from a labelled field | Raise it; count tags, not people |
MAIN-FEED-0013 | fanout.threshold, timeline.max or max-following above its ceiling | Lower it |
Two codes are runtime only: MAIN-FEED-0006 (a failed fan-out batch, served by merging at read time) and MAIN-FEED-0008 (a pushed value that loosens a limit is refused). See check codes.
mainsdk check has a feeds gate for MAIN-FEED-0010 to 0013, each reported with its file and line.
Swapping drivers
feeds.driver is db (the default, on your app's db stores) or local, an in-process SQLite store for tests. Your calls do not change when you change it.
Testing
driver: local keeps the graph, items and timelines in an in-process SQLite store, so tests run offline on the fake clock and the local job queue. See Test your app.
Pushed config
A push may only tighten (ADR-0017): feeds.page.end is never turned off, feeds.page.pause_after and feeds.max-following only go lower, feeds.trending.min-count only goes higher. A looser value is refused with MAIN-FEED-0008, the last good value stays, and the refusal is written to the audit log.
What is connected, and what is not
app.feeds (and import { feeds } from "mainsdk") is built on first use from the feeds section over the app's db SQL stores, tenancy, jobs, access and the log. These modules are not built yet, so set app.socialPorts.feeds = { ... } before the first use if you have your own:
| Port | What it does | Without it |
|---|---|---|
realtime | Publishes feeds.new with the pending count | The count is read with the timeline |
items | Reads the item record in the author's region to check its owner | Ownership is not checked against the feeds.items model |
accounts | Says whether users.private is set | Every account is public |
audience | Analytics' children rule | A child is known by the subject's own fields |
moderation | Says whether moderate removed an item | Nothing is removed by moderation |
erase | Registers feeds.erase with privacy.erase | Call the erase hook yourself |
Security and privacy
- A timeline entry holds only the item id, author id and time, and lives in the follower's region. Item records stay in their author's region.
- Follow, block and mute changes are audited without content and visible only to the person who made them.
privacy.eraseremoves a person's follows, blocks, mutes, item references, timeline entries and counts.- A link to an item goes through
links, not throughfeeds.
FAQ
Are likes, replies and reposts built in? No. Model them as records and count them for your ranker.
Can I push people more items while they read? No. A timeline never inserts items while a person reads; use the feeds.new count.
Next
- Build a feed with an end: the steps, end to end.
- Realtime updates
- Jobs and queues
- Moderation
Was this page helpful?