Feed/List Endpoint
A feed endpoint is just a list endpoint with stricter rules. Sort order must stay stable, cursor predicates must be explicit, and the handler should overfetch by one row so it can tell whether there is another page.
The request and cursor
type FeedRequest struct {
TenantID int
Query string
Topics []string
Cursor *FeedCursor
}
type FeedCursor struct {
CreatedAt time.Time
ID int64
}
Cursor pagination is opinionated on purpose. Use the same columns in the same order every time or the feed will drift under load.
Build the feed query
func anySlice[T any](values []T) []any {
out := make([]any, 0, len(values))
for _, v := range values {
out = append(out, v)
}
return out
}
func BuildFeedQuery(qq *quarry.Quarry, req FeedRequest) quarry.SQLer {
q := qq.Select(
"id",
"title",
"excerpt",
"topic",
"created_at",
).
From("posts").
Where(
quarry.Eq("tenant_id", req.TenantID),
quarry.OptionalILike("title", req.Query),
quarry.OptionalIn("topic", anySlice(req.Topics)...),
).
OrderBy("created_at DESC", "id DESC").
Limit(21)
if req.Cursor != nil {
q = q.Where(quarry.Or(
quarry.Lt("created_at", req.Cursor.CreatedAt),
quarry.And(
quarry.Eq("created_at", req.Cursor.CreatedAt),
quarry.Lt("id", req.Cursor.ID),
),
))
}
return q
}
Hydrate the rows
type FeedItem struct {
ID int64 `db:"id"`
Title string `db:"title"`
Excerpt string `db:"excerpt"`
Topic string `db:"topic"`
CreatedAt time.Time `db:"created_at"`
}
items, err := scan.All[FeedItem](ctx, db, BuildFeedQuery(qq, req))
if err != nil {
return nil, nil, err
}
The scan layer does not care that this is a feed. It just turns the rows into the struct shape you asked for.
Trim the overfetch and build the next cursor
hasNext := len(items) == 21
if hasNext {
items = items[:20]
}
var nextCursor *FeedCursor
if hasNext && len(items) > 0 {
last := items[len(items)-1]
nextCursor = &FeedCursor{
CreatedAt: last.CreatedAt,
ID: last.ID,
}
}
This is intentionally plain. The data flow is easier to understand when the feed logic is obvious rather than hidden behind a helper that tries to be clever.
What the SQL looks like
SELECT id, title, excerpt, topic, created_at
FROM posts
WHERE tenant_id = $1
AND (LOWER(title) LIKE LOWER($2) OR topic IN ($3, $4))
AND (
created_at < $5
OR (created_at = $5 AND id < $6)
)
ORDER BY created_at DESC, id DESC
LIMIT 21
created_at and id paired.