chadtmiller.com/hatk
AT Protocol app framework
1
0
---
title: Feeds
description: Define custom feeds that turn your indexed data into timelines.
---
# Feeds
Feeds are custom timelines powered by your indexed data. Each feed queries your SQLite database to produce a list of record URIs, and optionally enriches those results with author profiles and other metadata before returning them to the client.
## Defining a feed
Create a feed file in your `server/` directory using `defineFeed()`:
```typescript
// server/recent.ts
import { defineFeed } from '$hatk'
export default defineFeed({
collection: 'xyz.statusphere.status',
label: 'Recent',
async generate(ctx) {
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT uri, cid, indexed_at, created_at FROM "xyz.statusphere.status"`,
{ orderBy: 'created_at', order: 'DESC' },
)
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
},
})
```
This feed queries every status record, sorted newest-first, with automatic cursor-based pagination. The framework resolves the returned URIs into full records before sending them to the client.
## `defineFeed` options
| Field | Required | Description |
| ------------ | ------------------------------- | -------------------------------------------- |
| `collection` | Yes (unless `hydrate` provided) | The collection this feed queries |
| `label` | Yes | Human-readable name shown in `describeFeeds` |
| `generate` | Yes | Function that returns record URIs |
| `hydrate` | No | Function that enriches resolved records |
## The `generate` function
`generate` receives a context object and returns a list of AT URIs plus an optional cursor for pagination.
### Context reference
| Field | Type | Description |
| --------------------- | ------------------------------------------ | ----------------------------------------------------------- |
| `db.query` | function | Run SQL queries against your SQLite database |
| `params` | `Record<string, string>` | Query string parameters from the request |
| `limit` | number | Requested page size |
| `cursor` | string \| undefined | Pagination cursor from the client |
| `viewer` | `{ did: string; handle?: string }` \| null | The authenticated user, or null |
| `ok` | function | Wraps your return value with type checking |
| `paginate` | function | Run a paginated query (handles cursor, ORDER BY, LIMIT) |
| `spaceFilter` | function | Permissioned-space gate for SQL built without `paginate` |
| `packCursor` | function | Encode a `(primary, cid)` pair into an opaque cursor string |
| `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` or null |
| `isTakendown` | function | Check if a DID has been taken down |
| `filterTakendownDids` | function | Filter a list of DIDs, returning those that are taken down |
## Pagination with `ctx.paginate()`
`paginate` is the recommended way to handle cursor-based pagination. It takes a SQL query and handles cursor unpacking, `WHERE`/`AND` clause injection, `ORDER BY`, `LIMIT`, `hasMore` detection, and cursor packing automatically.
### Basic usage
```typescript
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT uri, cid, indexed_at FROM "xyz.statusphere.status"`,
)
```
### With parameters and custom sort
Pass SQL parameters and specify which column to sort by:
```typescript
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT p.uri, p.cid, p.played_time
FROM "fm.teal.alpha.feed.play__artists" a
JOIN "fm.teal.alpha.feed.play" p ON p.uri = a.parent_uri
WHERE a.artist_name = $1`,
{ params: [artist], orderBy: 'p.played_time' },
)
```
`paginate` appends cursor conditions, `ORDER BY`, and `LIMIT` to your query. You provide the base `SELECT` and any `WHERE` clauses for filtering; `paginate` adds the rest.
It also applies the [permissioned-space](/guides/spaces) gate, so a feed shows each viewer the spaces they may read and everyone else the public rows alone. A feed that builds its own SQL over a space-backed table is refused with an error naming the table and `ctx.spaceFilter`; a feed over anything else is never affected.
### Using the viewer
Feeds can use `ctx.viewer` to personalize results. For example, a "following" feed that shows records from accounts the viewer follows:
```typescript
import { defineFeed } from '$hatk'
export default defineFeed({
collection: 'fm.teal.alpha.feed.play',
label: 'Following',
async generate(ctx) {
const actorDid = ctx.params.actor || ctx.viewer?.did
if (!actorDid) {
return ctx.ok({ uris: [], cursor: undefined })
}
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT p.uri, p.cid, p.played_time
FROM "fm.teal.alpha.feed.play" p
INNER JOIN "app.bsky.graph.follow" f ON f.subject = p.did
WHERE f.did = $1`,
{ params: [actorDid], orderBy: 'p.played_time' },
)
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
},
})
```
### Manual cursors
If you need more control than `paginate` provides, use `packCursor` and `unpackCursor` directly. They implement a two-field cursor pattern using a sort value (like `indexed_at`) and the record's `cid` as a tiebreaker:
```typescript
// Encode: packCursor(indexed_at, cid) → "MjAyNS0wMS..."
const cursor = packCursor(last.indexed_at, last.cid)
// Decode: unpackCursor("MjAyNS0wMS...") → { primary: "2025-01-01T...", cid: "bafyrei..." }
const parsed = unpackCursor(cursor)
```
## Hydration
The optional `hydrate` function enriches feed results with additional data. After `generate` returns URIs, the framework resolves them into full records, then passes those records to `hydrate`.
### Hydrate function signature
`hydrate` receives a `BaseContext` and an array of `Row<T>` items (the resolved records). Each row has `uri`, `did`, `handle`, and `value`.
### BaseContext reference
| Field | Type | Description |
| ------------ | ------------------------------------------ | ------------------------------------------------------- |
| `viewer` | `{ did: string; handle?: string }` \| null | The authenticated user, or null |
| `db.query` | function | Run SQL queries against your SQLite database |
| `getRecords` | function | Fetch records by URI from another collection |
| `lookup` | function | Look up records by a field value (e.g. profiles by DID) |
| `count` | function | Count records by field value |
| `labels` | function | Query labels for a list of URIs |
| `blobUrl` | function | Resolve a blob reference to a CDN URL |
### Example with hydration
This feed queries status records and hydrates each one with the author's profile:
```typescript
import { defineFeed, views, type Status, type Profile, type BaseContext, type Row } from '$hatk'
export default defineFeed({
collection: 'xyz.statusphere.status',
label: 'Recent',
hydrate: (ctx, items) => hydrateStatuses(ctx, items as Row<Status>[]),
async generate(ctx) {
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT uri, cid, indexed_at, created_at FROM "xyz.statusphere.status"`,
{ orderBy: 'created_at', order: 'DESC' },
)
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
},
})
async function hydrateStatuses(ctx: BaseContext, items: Row<Status>[]) {
const dids = [...new Set(items.map((item) => item.did).filter(Boolean))]
const profiles = await ctx.lookup<Profile>('app.bsky.actor.profile', 'did', dids)
return items.map((item) => {
const author = profiles.get(item.did)
return views.statusView({
uri: item.uri,
status: item.value.status,
createdAt: item.value.createdAt,
indexedAt: item.indexed_at,
author: views.profileView({
did: item.did,
handle: item.handle || item.did,
displayName: author?.value.displayName,
avatar: author ? ctx.blobUrl(author.did, author.value.avatar, 'avatar') : undefined,
}),
})
})
}
```
Key patterns:
- **Batch lookups** — `ctx.lookup()` fetches records for multiple DIDs in one call. Collect unique DIDs first to avoid duplicate queries.
- **`ctx.blobUrl()`** — converts a blob reference (like an avatar) into a CDN URL the client can load.
- **View builders** — `views.statusView()` and `views.profileView()` are generated from your lexicon's view definitions, providing type-safe construction.
### Hydration with viewer context
Hydration can also use `ctx.viewer` to add viewer-specific data like bookmarks:
```typescript
async function hydratePlays(ctx: BaseContext, items: Row<Play>[]) {
const dids = [...new Set(items.map((item) => item.did).filter(Boolean))]
const profiles = await ctx.lookup<Profile>('app.bsky.actor.profile', 'did', dids)
// Load viewer's bookmarks
const bookmarks = new Map<string, string>()
if (ctx.viewer?.did && items.length > 0) {
const rows = await ctx.db.query(`SELECT subject, uri FROM "community.lexicon.bookmarks.bookmark" WHERE did = $1`, [
ctx.viewer.did,
])
for (const row of rows as { subject: string; uri: string }[]) {
bookmarks.set(row.subject, row.uri)
}
}
return items.map((item) => {
const author = profiles.get(item.did)
return views.playView({
record: { uri: item.uri, did: item.did, handle: item.handle, ...item.value },
author: author
? {
did: author.did,
handle: author.handle,
displayName: author.value.displayName,
avatar: ctx.blobUrl(author.did, author.value.avatar),
}
: undefined,
viewerBookmark: bookmarks.get(item.uri),
})
})
}
```
## Generating a feed
Use the CLI to scaffold a new feed file:
```bash
hatk generate feed recent
```
This creates the file with the right imports and structure.