See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Bluesky custom feeds for 1k / 10k like crossings

Two feeds served from this process:

top-1k — posts that have crossed 1,000 likes
top-10k — posts that have crossed 10,000 likes

Ordered by the time each post first crossed the threshold, newest first.
Posts are never removed if their like count later drops — per requirement.

Ordering is backed by the existing `notified_thresholds` SQLite table
populated by ThresholdScanJob, which already stores
(subject_uri, threshold, fired_at) dedup rows. No new data pipeline
needed — every detected crossing is already recorded there regardless of
whether FIREHOSE_WEBHOOK_URL_* is configured.

Endpoints (all on the web process):

GET /.well-known/did.json — did:web:<host>
GET /xrpc/app.bsky.feed.describeFeedGenerator — metadata
GET /xrpc/app.bsky.feed.getFeedSkeleton — ranked post URIs

Pagination uses a keyset cursor on (fired_at DESC, subject_uri DESC) so
it's stable under concurrent inserts.

Also adds `node ace feeds:publish` to bootstrap the feed records on a
real atproto account's PDS. Requires FEED_PUBLISHER_HANDLE and
FEED_PUBLISHER_APP_PASSWORD. After publishing, set FEED_PUBLISHER_DID
so describeFeedGenerator advertises the feeds.

https://claude.ai/code/session_01NJ15XWB5biJMu4e3zVHdC7

Claude 93c13dc9 1b2db53e

+928
+5
.env.example
··· 32 32 # Backfill cap (number of posts; defaults to 10000) 33 33 BACKFILL_MAX_POSTS=10000 34 34 35 + # Feed generator — DID of the atproto account that owns the published 36 + # app.bsky.feed.generator records (top-1k, top-10k). Unset => feeds are 37 + # served but not discoverable via describeFeedGenerator. 38 + FEED_PUBLISHER_DID= 39 + 35 40 # PostHog (analytics) 36 41 # Tracking is disabled when POSTHOG_API_KEY is unset. 37 42 POSTHOG_API_KEY=
+3
.env.test
··· 10 10 CLICKHOUSE_DB=favs_test 11 11 CLICKHOUSE_USER=favs 12 12 CLICKHOUSE_PASSWORD= 13 + 14 + # Feed generator — tests override this per-case where relevant. 15 + FEED_PUBLISHER_DID=did:plc:testpublisher
+102
app/controllers/feed_generator_controller.ts
··· 1 + import type { HttpContext } from '@adonisjs/core/http' 2 + import { 3 + FEEDS, 4 + FEED_SKELETON_DEFAULT_LIMIT, 5 + FEED_SKELETON_MAX_LIMIT, 6 + InvalidCursorError, 7 + UnknownFeedError, 8 + didDocument, 9 + feedUri, 10 + getSkeleton, 11 + publisherDid, 12 + serviceDid, 13 + } from '#services/feed_generator' 14 + 15 + /** 16 + * HTTP surface for the Bluesky custom feed generator. 17 + * 18 + * GET /.well-known/did.json — did:web document 19 + * GET /xrpc/app.bsky.feed.describeFeedGenerator — lists our two feeds 20 + * GET /xrpc/app.bsky.feed.getFeedSkeleton — ranked post skeleton 21 + * 22 + * The XRPC endpoints follow the lexicon shape defined at 23 + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/feed 24 + */ 25 + export default class FeedGeneratorController { 26 + async didDocument({ response }: HttpContext) { 27 + response.header('Content-Type', 'application/json') 28 + response.header('Cache-Control', 'public, max-age=3600') 29 + return response.ok(didDocument()) 30 + } 31 + 32 + async describeFeedGenerator({ response }: HttpContext) { 33 + const pub = publisherDid() 34 + const feeds = pub 35 + ? FEEDS.map((f) => ({ uri: `at://${pub}/app.bsky.feed.generator/${f.rkey}` })) 36 + : [] 37 + 38 + response.header('Cache-Control', 'public, max-age=300') 39 + return response.ok({ 40 + did: serviceDid(), 41 + feeds, 42 + }) 43 + } 44 + 45 + async getFeedSkeleton({ request, response }: HttpContext) { 46 + const feed = request.qs().feed 47 + if (typeof feed !== 'string' || feed.length === 0) { 48 + return response.badRequest({ 49 + error: 'InvalidRequest', 50 + message: 'Missing required parameter: feed', 51 + }) 52 + } 53 + 54 + const limit = parseLimit(request.qs().limit) 55 + if (limit === null) { 56 + return response.badRequest({ 57 + error: 'InvalidRequest', 58 + message: `limit must be an integer in [1, ${FEED_SKELETON_MAX_LIMIT}]`, 59 + }) 60 + } 61 + 62 + const cursorRaw = request.qs().cursor 63 + const cursor = typeof cursorRaw === 'string' && cursorRaw.length > 0 ? cursorRaw : undefined 64 + 65 + try { 66 + const result = await getSkeleton({ feedUri: feed, cursor, limit }) 67 + response.header('Cache-Control', 'no-store') 68 + return response.ok(result) 69 + } catch (err) { 70 + if (err instanceof UnknownFeedError) { 71 + return response.badRequest({ 72 + error: 'UnknownFeed', 73 + message: `Unknown feed URI: ${feed}`, 74 + }) 75 + } 76 + if (err instanceof InvalidCursorError) { 77 + return response.badRequest({ 78 + error: 'InvalidRequest', 79 + message: 'Invalid cursor', 80 + }) 81 + } 82 + throw err 83 + } 84 + } 85 + } 86 + 87 + /** 88 + * Parse the `limit` query parameter. Returns: 89 + * - the default when absent 90 + * - the integer value when within [1, MAX] 91 + * - null when present-but-invalid, signalling a 400 to the caller 92 + */ 93 + function parseLimit(raw: unknown): number | null { 94 + if (raw === undefined || raw === '') return FEED_SKELETON_DEFAULT_LIMIT 95 + if (typeof raw !== 'string') return null 96 + const n = Number(raw) 97 + if (!Number.isInteger(n)) return null 98 + if (n < 1 || n > FEED_SKELETON_MAX_LIMIT) return null 99 + return n 100 + } 101 + 102 + export { feedUri }
+215
app/services/feed_generator.ts
··· 1 + import env from '#start/env' 2 + import NotifiedThreshold from '#models/notified_threshold' 3 + import { parseAtUri } from '#lib/atproto/index' 4 + 5 + /** 6 + * Feed-generator service. Two hardcoded feeds: 7 + * 8 + * top-1k — posts that have crossed 1,000 likes 9 + * top-10k — posts that have crossed 10,000 likes 10 + * 11 + * Ordering is by the time a post *first crossed* the threshold, newest first. 12 + * Posts are never removed from a feed if their like count later drops. 13 + * 14 + * Source of truth: the `notified_thresholds` SQLite table populated by 15 + * `ThresholdScanJob` — each row is `(subject_uri, threshold, fired_at)` and 16 + * `fired_at` is the detection timestamp. 17 + */ 18 + 19 + export interface FeedDefinition { 20 + readonly rkey: string 21 + readonly threshold: number 22 + readonly displayName: string 23 + readonly description: string 24 + } 25 + 26 + export const FEEDS: readonly FeedDefinition[] = [ 27 + { 28 + rkey: 'top-1k', 29 + threshold: 1000, 30 + displayName: 'Past 1k likes', 31 + description: 'Posts as they cross 1,000 likes, newest-crossing first. Powered by favs.blue.', 32 + }, 33 + { 34 + rkey: 'top-10k', 35 + threshold: 10_000, 36 + displayName: 'Past 10k likes', 37 + description: 'Posts as they cross 10,000 likes, newest-crossing first. Powered by favs.blue.', 38 + }, 39 + ] as const 40 + 41 + export const FEED_SKELETON_MAX_LIMIT = 100 42 + export const FEED_SKELETON_DEFAULT_LIMIT = 50 43 + 44 + // --------------------------------------------------------------------------- 45 + // Identity / record locations 46 + // --------------------------------------------------------------------------- 47 + 48 + /** 49 + * DID of the feed-generator service itself. We use `did:web:<hostname>` so the 50 + * DID document is self-hosted at `/.well-known/did.json` — no PLC registration 51 + * required. Derived from `APP_URL`. 52 + */ 53 + export function serviceDid(): string { 54 + const url = new URL(env.get('APP_URL')) 55 + return `did:web:${url.host}` 56 + } 57 + 58 + /** 59 + * DID of the atproto account that owns the feed records. Feeds must be 60 + * published on some real account's PDS; this is the `creator` field of the 61 + * `app.bsky.feed.generator` record. Configured via FEED_PUBLISHER_DID. 62 + * 63 + * Returns null when unconfigured — in that case describeFeedGenerator returns 64 + * an empty feed list and getFeedSkeleton cannot resolve any feed URIs. 65 + * 66 + * Read from `process.env` directly (same pattern as the firehose webhook 67 + * URLs in ThresholdScanJob) so tests can override it without rebuilding the 68 + * AdonisJS Env instance. 69 + */ 70 + export function publisherDid(): string | null { 71 + const value = process.env.FEED_PUBLISHER_DID 72 + return value && value.length > 0 ? value : null 73 + } 74 + 75 + /** Canonical AT-URI of a feed record, or null if no publisher configured. */ 76 + export function feedUri(rkey: string): string | null { 77 + const pub = publisherDid() 78 + if (!pub) return null 79 + return `at://${pub}/app.bsky.feed.generator/${rkey}` 80 + } 81 + 82 + /** 83 + * Find the feed definition matching an AT-URI received in getFeedSkeleton. 84 + * Returns null if the URI doesn't match a known feed under our publisher DID. 85 + */ 86 + export function findFeedByUri(uri: string): FeedDefinition | null { 87 + const pub = publisherDid() 88 + if (!pub) return null 89 + 90 + let parsed 91 + try { 92 + parsed = parseAtUri(uri) 93 + } catch { 94 + return null 95 + } 96 + if (parsed.did !== pub) return null 97 + if (parsed.collection !== 'app.bsky.feed.generator') return null 98 + return FEEDS.find((f) => f.rkey === parsed.rkey) ?? null 99 + } 100 + 101 + // --------------------------------------------------------------------------- 102 + // DID document 103 + // --------------------------------------------------------------------------- 104 + 105 + /** 106 + * DID document served at `/.well-known/did.json`. Declares the 107 + * `BskyFeedGenerator` service endpoint — the AppView uses this to look up 108 + * where to hit `getFeedSkeleton`. 109 + * 110 + * Spec: https://github.com/bluesky-social/feed-generator 111 + */ 112 + export function didDocument(): Record<string, unknown> { 113 + const did = serviceDid() 114 + const appUrl = env.get('APP_URL') 115 + return { 116 + '@context': ['https://www.w3.org/ns/did/v1'], 117 + 'id': did, 118 + 'service': [ 119 + { 120 + id: '#bsky_fg', 121 + type: 'BskyFeedGenerator', 122 + serviceEndpoint: appUrl, 123 + }, 124 + ], 125 + } 126 + } 127 + 128 + // --------------------------------------------------------------------------- 129 + // Skeleton query 130 + // --------------------------------------------------------------------------- 131 + 132 + export interface SkeletonArgs { 133 + /** The AT-URI passed by the AppView in the `feed` query param. */ 134 + feedUri: string 135 + /** Opaque pagination cursor returned by a previous call, if any. */ 136 + cursor?: string | undefined 137 + /** Desired page size (clamped by the caller to [1, MAX_LIMIT]). */ 138 + limit: number 139 + } 140 + 141 + export interface SkeletonResult { 142 + feed: Array<{ post: string }> 143 + cursor?: string 144 + } 145 + 146 + export class UnknownFeedError extends Error { 147 + constructor(uri: string) { 148 + super(`Unknown feed URI: ${uri}`) 149 + this.name = 'UnknownFeedError' 150 + } 151 + } 152 + 153 + export class InvalidCursorError extends Error { 154 + constructor(cursor: string) { 155 + super(`Invalid cursor: ${cursor}`) 156 + this.name = 'InvalidCursorError' 157 + } 158 + } 159 + 160 + /** 161 + * Build a feed skeleton for the given feed URI. 162 + * 163 + * Pagination cursor format: `<firedAt>:<subjectUri>` — a keyset cursor on 164 + * (firedAt DESC, subjectUri DESC). Each row in `notified_thresholds` is 165 + * unique by (subject_uri, threshold), so subjectUri alone disambiguates 166 + * rows that share a firedAt millisecond. 167 + */ 168 + export async function getSkeleton(args: SkeletonArgs): Promise<SkeletonResult> { 169 + const feed = findFeedByUri(args.feedUri) 170 + if (!feed) throw new UnknownFeedError(args.feedUri) 171 + 172 + const query = NotifiedThreshold.query() 173 + .where('threshold', feed.threshold) 174 + .orderBy('fired_at', 'desc') 175 + .orderBy('subject_uri', 'desc') 176 + .limit(args.limit) 177 + 178 + if (args.cursor !== undefined && args.cursor !== '') { 179 + const parsed = parseCursor(args.cursor) 180 + // Keyset walk: rows strictly "after" the cursor under (fired_at DESC, 181 + // subject_uri DESC). Either firedAt is smaller, or firedAt is equal 182 + // and subjectUri is lexicographically smaller. 183 + query.where((q) => { 184 + q.where('fired_at', '<', parsed.firedAt).orWhere((inner) => { 185 + inner.where('fired_at', parsed.firedAt).andWhere('subject_uri', '<', parsed.subjectUri) 186 + }) 187 + }) 188 + } 189 + 190 + const rows = await query 191 + const items = rows.map((r) => ({ post: r.subjectUri })) 192 + 193 + let nextCursor: string | undefined 194 + if (rows.length === args.limit && rows.length > 0) { 195 + const last = rows[rows.length - 1] 196 + nextCursor = encodeCursor(last.firedAt, last.subjectUri) 197 + } 198 + 199 + return nextCursor ? { feed: items, cursor: nextCursor } : { feed: items } 200 + } 201 + 202 + function encodeCursor(firedAt: number, subjectUri: string): string { 203 + return `${firedAt}:${subjectUri}` 204 + } 205 + 206 + function parseCursor(raw: string): { firedAt: number; subjectUri: string } { 207 + const idx = raw.indexOf(':') 208 + if (idx <= 0 || idx === raw.length - 1) throw new InvalidCursorError(raw) 209 + const firedAt = Number(raw.slice(0, idx)) 210 + const subjectUri = raw.slice(idx + 1) 211 + if (!Number.isFinite(firedAt) || !Number.isInteger(firedAt)) { 212 + throw new InvalidCursorError(raw) 213 + } 214 + return { firedAt, subjectUri } 215 + }
+121
commands/feeds_publish.ts
··· 1 + import { BaseCommand, flags } from '@adonisjs/core/ace' 2 + import { CommandOptions } from '@adonisjs/core/types/ace' 3 + import { AtpAgent } from '@atproto/api' 4 + import { FEEDS, publisherDid, serviceDid } from '#services/feed_generator' 5 + 6 + /** 7 + * Ace command: `node ace feeds:publish` 8 + * 9 + * Publishes `app.bsky.feed.generator` records on an atproto account's PDS for 10 + * each configured feed (top-1k, top-10k). This is a one-shot bootstrap step 11 + * that makes the feeds discoverable in Bluesky clients. 12 + * 13 + * Required env / flags: 14 + * FEED_PUBLISHER_HANDLE — handle that owns the feed records 15 + * FEED_PUBLISHER_APP_PASSWORD — an app password for that account 16 + * FEED_PUBLISHER_DID — (optional) asserted DID to sanity-check 17 + * 18 + * The service DID served at the generator endpoint is derived from APP_URL 19 + * (did:web:<host>) — the feed record points to that DID, not the publisher. 20 + * 21 + * Re-running the command is safe: putRecord is idempotent on (repo, 22 + * collection, rkey). 23 + */ 24 + export default class FeedsPublish extends BaseCommand { 25 + static commandName = 'feeds:publish' 26 + static description = 'Publish app.bsky.feed.generator records for top-1k and top-10k' 27 + static options: CommandOptions = { startApp: true } 28 + 29 + @flags.string({ description: 'PDS service URL', default: 'https://bsky.social' }) 30 + declare pds: string 31 + 32 + @flags.boolean({ description: "Don't write records — print what would be published" }) 33 + declare dryRun: boolean 34 + 35 + async run() { 36 + const handle = process.env.FEED_PUBLISHER_HANDLE 37 + const password = process.env.FEED_PUBLISHER_APP_PASSWORD 38 + if (!handle || !password) { 39 + this.logger.error( 40 + 'FEED_PUBLISHER_HANDLE and FEED_PUBLISHER_APP_PASSWORD must be set in the environment' 41 + ) 42 + this.exitCode = 1 43 + return 44 + } 45 + 46 + const svcDid = serviceDid() 47 + this.logger.info(`Service DID (will be set on each feed record): ${svcDid}`) 48 + 49 + const agent = new AtpAgent({ service: this.pds }) 50 + 51 + try { 52 + await agent.login({ identifier: handle, password }) 53 + } catch (err) { 54 + this.logger.error(`Login failed: ${err instanceof Error ? err.message : String(err)}`) 55 + this.exitCode = 1 56 + return 57 + } 58 + 59 + const accountDid = agent.session?.did 60 + if (!accountDid) { 61 + this.logger.error('Login succeeded but no session DID returned') 62 + this.exitCode = 1 63 + return 64 + } 65 + 66 + this.logger.info(`Authenticated as ${handle} (${accountDid})`) 67 + 68 + const expected = publisherDid() 69 + if (expected && expected !== accountDid) { 70 + this.logger.error( 71 + `FEED_PUBLISHER_DID (${expected}) does not match the account DID (${accountDid}). ` + 72 + 'Refusing to publish under the wrong account.' 73 + ) 74 + this.exitCode = 1 75 + return 76 + } 77 + if (!expected) { 78 + this.logger.warning( 79 + `FEED_PUBLISHER_DID is not set. After publishing, set FEED_PUBLISHER_DID=${accountDid} ` + 80 + 'so the web process can advertise these feeds in describeFeedGenerator.' 81 + ) 82 + } 83 + 84 + const createdAt = new Date().toISOString() 85 + for (const feed of FEEDS) { 86 + const record = { 87 + $type: 'app.bsky.feed.generator', 88 + did: svcDid, 89 + displayName: feed.displayName, 90 + description: feed.description, 91 + createdAt, 92 + } 93 + 94 + if (this.dryRun) { 95 + this.logger.info(`[dry-run] Would putRecord rkey=${feed.rkey}:`) 96 + this.logger.info(JSON.stringify(record, null, 2)) 97 + continue 98 + } 99 + 100 + try { 101 + const result = await agent.com.atproto.repo.putRecord({ 102 + repo: accountDid, 103 + collection: 'app.bsky.feed.generator', 104 + rkey: feed.rkey, 105 + record, 106 + }) 107 + this.logger.info(`Published ${feed.rkey} → ${result.data.uri}`) 108 + } catch (err) { 109 + this.logger.error( 110 + `Failed to publish ${feed.rkey}: ${err instanceof Error ? err.message : String(err)}` 111 + ) 112 + this.exitCode = 1 113 + return 114 + } 115 + } 116 + 117 + if (!this.dryRun) { 118 + this.logger.info('All feeds published.') 119 + } 120 + } 121 + }
+5
start/env.ts
··· 45 45 FIREHOSE_WEBHOOK_URL_1K: Env.schema.string.optional(), 46 46 FIREHOSE_WEBHOOK_URL_10K: Env.schema.string.optional(), 47 47 48 + // Feed generator (web): DID of the atproto account that owns the 49 + // published `app.bsky.feed.generator` records. Unset => feeds are not 50 + // discoverable (describe endpoint returns an empty list). 51 + FEED_PUBLISHER_DID: Env.schema.string.optional(), 52 + 48 53 // Backfill 49 54 BACKFILL_MAX_POSTS: Env.schema.number.optional(), 50 55
+18
start/routes.ts
··· 19 19 const HealthChecksController = () => import('#controllers/health_checks_controller') 20 20 const OgImageController = () => import('#controllers/og_image_controller') 21 21 const SitemapController = () => import('#controllers/sitemap_controller') 22 + const FeedGeneratorController = () => import('#controllers/feed_generator_controller') 22 23 23 24 // --------------------------------------------------------------------------- 24 25 // Landing ··· 100 101 101 102 router.get('/sitemap.xml', [SitemapController, 'index']).as('sitemap.index') 102 103 router.get('/sitemaps/:n.xml', [SitemapController, 'chunk']).as('sitemap.chunk') 104 + 105 + // --------------------------------------------------------------------------- 106 + // Bluesky feed generator 107 + // --------------------------------------------------------------------------- 108 + 109 + router.get('/.well-known/did.json', [FeedGeneratorController, 'didDocument']).as('feeds.did') 110 + 111 + router 112 + .get('/xrpc/app.bsky.feed.describeFeedGenerator', [ 113 + FeedGeneratorController, 114 + 'describeFeedGenerator', 115 + ]) 116 + .as('feeds.describe') 117 + 118 + router 119 + .get('/xrpc/app.bsky.feed.getFeedSkeleton', [FeedGeneratorController, 'getFeedSkeleton']) 120 + .as('feeds.getSkeleton') 103 121 104 122 // --------------------------------------------------------------------------- 105 123 // Health checks
+207
tests/functional/feed_generator.spec.ts
··· 1 + /** 2 + * Functional tests for the Bluesky feed-generator endpoints: 3 + * 4 + * GET /.well-known/did.json — DID document 5 + * GET /xrpc/app.bsky.feed.describeFeedGenerator — feed metadata 6 + * GET /xrpc/app.bsky.feed.getFeedSkeleton?feed=... — ranked post URIs 7 + * 8 + * Ordering is by `notified_thresholds.fired_at DESC` — newest threshold 9 + * crossing first. The dedup composite PK `(subject_uri, threshold)` means a 10 + * post appears at most once per feed. 11 + */ 12 + import { test } from '@japa/runner' 13 + import testUtils from '@adonisjs/core/services/test_utils' 14 + import NotifiedThreshold from '#models/notified_threshold' 15 + 16 + const PUBLISHER = 'did:plc:testpublisher' 17 + 18 + test.group('Feed generator', (group) => { 19 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 20 + 21 + // --------------------------------------------------------------------- 22 + // DID document 23 + // --------------------------------------------------------------------- 24 + 25 + test('GET /.well-known/did.json returns a did:web document', async ({ client, assert }) => { 26 + const response = await client.get('/.well-known/did.json') 27 + response.assertStatus(200) 28 + const body = response.body() 29 + assert.match(body.id, /^did:web:/) 30 + assert.isArray(body['@context']) 31 + assert.include(body['@context'], 'https://www.w3.org/ns/did/v1') 32 + }) 33 + 34 + test('GET /.well-known/did.json declares a BskyFeedGenerator service', async ({ 35 + client, 36 + assert, 37 + }) => { 38 + const response = await client.get('/.well-known/did.json') 39 + const body = response.body() 40 + assert.isArray(body.service) 41 + const svc = body.service.find((s: { type: string }) => s.type === 'BskyFeedGenerator') 42 + assert.isDefined(svc, 'expected a service of type BskyFeedGenerator') 43 + assert.equal(svc.id, '#bsky_fg') 44 + assert.isString(svc.serviceEndpoint) 45 + }) 46 + 47 + // --------------------------------------------------------------------- 48 + // describeFeedGenerator 49 + // --------------------------------------------------------------------- 50 + 51 + test('describeFeedGenerator lists top-1k and top-10k', async ({ client, assert }) => { 52 + const response = await client.get('/xrpc/app.bsky.feed.describeFeedGenerator') 53 + response.assertStatus(200) 54 + const body = response.body() 55 + assert.match(body.did, /^did:web:/) 56 + assert.isArray(body.feeds) 57 + const uris = body.feeds.map((f: { uri: string }) => f.uri).sort() 58 + assert.deepEqual(uris, [ 59 + `at://${PUBLISHER}/app.bsky.feed.generator/top-10k`, 60 + `at://${PUBLISHER}/app.bsky.feed.generator/top-1k`, 61 + ]) 62 + }) 63 + 64 + test('describeFeedGenerator returns empty feeds when no publisher configured', async ({ 65 + client, 66 + assert, 67 + }) => { 68 + const original = process.env.FEED_PUBLISHER_DID 69 + delete process.env.FEED_PUBLISHER_DID 70 + try { 71 + const response = await client.get('/xrpc/app.bsky.feed.describeFeedGenerator') 72 + response.assertStatus(200) 73 + assert.deepEqual(response.body().feeds, []) 74 + } finally { 75 + if (original !== undefined) process.env.FEED_PUBLISHER_DID = original 76 + } 77 + }) 78 + 79 + // --------------------------------------------------------------------- 80 + // getFeedSkeleton 81 + // --------------------------------------------------------------------- 82 + 83 + test('getFeedSkeleton returns 400 InvalidRequest for missing feed param', async ({ 84 + client, 85 + assert, 86 + }) => { 87 + const response = await client.get('/xrpc/app.bsky.feed.getFeedSkeleton') 88 + response.assertStatus(400) 89 + assert.equal(response.body().error, 'InvalidRequest') 90 + }) 91 + 92 + test('getFeedSkeleton returns 400 UnknownFeed for an unrecognised feed URI', async ({ 93 + client, 94 + assert, 95 + }) => { 96 + const response = await client.get( 97 + '/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:nope/app.bsky.feed.generator/nope' 98 + ) 99 + response.assertStatus(400) 100 + assert.equal(response.body().error, 'UnknownFeed') 101 + }) 102 + 103 + test('getFeedSkeleton for top-1k returns posts that crossed 1k, newest-crossing first', async ({ 104 + client, 105 + assert, 106 + }) => { 107 + const now = Date.now() 108 + await NotifiedThreshold.create({ 109 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/older', 110 + threshold: 1000, 111 + firedAt: now - 2000, 112 + }) 113 + await NotifiedThreshold.create({ 114 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/newer', 115 + threshold: 1000, 116 + firedAt: now - 1000, 117 + }) 118 + await NotifiedThreshold.create({ 119 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/unrelated', 120 + threshold: 10_000, 121 + firedAt: now, 122 + }) 123 + 124 + const response = await client.get( 125 + `/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://${PUBLISHER}/app.bsky.feed.generator/top-1k` 126 + ) 127 + response.assertStatus(200) 128 + const body = response.body() 129 + assert.isArray(body.feed) 130 + const uris = body.feed.map((f: { post: string }) => f.post) 131 + assert.deepEqual(uris, [ 132 + 'at://did:plc:a/app.bsky.feed.post/newer', 133 + 'at://did:plc:a/app.bsky.feed.post/older', 134 + ]) 135 + }) 136 + 137 + test('getFeedSkeleton for top-10k only returns 10k crossings', async ({ client, assert }) => { 138 + await NotifiedThreshold.create({ 139 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/one-k', 140 + threshold: 1000, 141 + firedAt: Date.now(), 142 + }) 143 + await NotifiedThreshold.create({ 144 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/ten-k', 145 + threshold: 10_000, 146 + firedAt: Date.now(), 147 + }) 148 + 149 + const response = await client.get( 150 + `/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://${PUBLISHER}/app.bsky.feed.generator/top-10k` 151 + ) 152 + response.assertStatus(200) 153 + const uris = response.body().feed.map((f: { post: string }) => f.post) 154 + assert.deepEqual(uris, ['at://did:plc:a/app.bsky.feed.post/ten-k']) 155 + }) 156 + 157 + test('getFeedSkeleton paginates with a cursor', async ({ client, assert }) => { 158 + const baseTs = Date.now() 159 + // Insert 5 entries with strictly decreasing fired_at so ordering is stable. 160 + for (let i = 0; i < 5; i++) { 161 + await NotifiedThreshold.create({ 162 + subjectUri: `at://did:plc:a/app.bsky.feed.post/${i}`, 163 + threshold: 1000, 164 + firedAt: baseTs - i * 1000, 165 + }) 166 + } 167 + 168 + const feedParam = encodeURIComponent(`at://${PUBLISHER}/app.bsky.feed.generator/top-1k`) 169 + const first = await client.get(`/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}&limit=2`) 170 + first.assertStatus(200) 171 + const firstBody = first.body() 172 + assert.lengthOf(firstBody.feed, 2) 173 + assert.isString(firstBody.cursor) 174 + assert.deepEqual( 175 + firstBody.feed.map((f: { post: string }) => f.post), 176 + ['at://did:plc:a/app.bsky.feed.post/0', 'at://did:plc:a/app.bsky.feed.post/1'] 177 + ) 178 + 179 + const second = await client.get( 180 + `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}&limit=2&cursor=${encodeURIComponent(firstBody.cursor)}` 181 + ) 182 + second.assertStatus(200) 183 + const secondBody = second.body() 184 + assert.deepEqual( 185 + secondBody.feed.map((f: { post: string }) => f.post), 186 + ['at://did:plc:a/app.bsky.feed.post/2', 'at://did:plc:a/app.bsky.feed.post/3'] 187 + ) 188 + }) 189 + 190 + test('getFeedSkeleton caps limit at 100 and rejects negative limits', async ({ 191 + client, 192 + assert, 193 + }) => { 194 + const feedParam = encodeURIComponent(`at://${PUBLISHER}/app.bsky.feed.generator/top-1k`) 195 + const tooBig = await client.get( 196 + `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}&limit=9999` 197 + ) 198 + // 9999 is outside [1, 100] — the spec defines this as InvalidRequest. 199 + tooBig.assertStatus(400) 200 + assert.equal(tooBig.body().error, 'InvalidRequest') 201 + 202 + const negative = await client.get( 203 + `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}&limit=-1` 204 + ) 205 + negative.assertStatus(400) 206 + }) 207 + })
+252
tests/unit/feed_generator.spec.ts
··· 1 + /** 2 + * Unit tests for the feed-generator service. 3 + * 4 + * Covers: feed-URI resolution, DID document shape, and the SQLite-backed 5 + * skeleton query (ordering, filtering by threshold, keyset pagination). 6 + * 7 + * The HTTP layer is thin and delegates here — see tests/functional/ 8 + * feed_generator.spec.ts for end-to-end coverage under a real HTTP server. 9 + */ 10 + import { test } from '@japa/runner' 11 + import testUtils from '@adonisjs/core/services/test_utils' 12 + import NotifiedThreshold from '#models/notified_threshold' 13 + import { 14 + FEEDS, 15 + UnknownFeedError, 16 + didDocument, 17 + feedUri, 18 + findFeedByUri, 19 + getSkeleton, 20 + publisherDid, 21 + serviceDid, 22 + } from '#services/feed_generator' 23 + 24 + const PUBLISHER = 'did:plc:testpublisher' 25 + 26 + test.group('feed_generator / config', () => { 27 + test('exports exactly the two required feeds', ({ assert }) => { 28 + const rkeys = FEEDS.map((f) => f.rkey).sort() 29 + assert.deepEqual(rkeys, ['top-10k', 'top-1k']) 30 + const thresholds = FEEDS.map((f) => f.threshold).sort((a, b) => a - b) 31 + assert.deepEqual(thresholds, [1000, 10_000]) 32 + }) 33 + 34 + test('serviceDid derives did:web from APP_URL hostname', ({ assert }) => { 35 + // APP_URL in .env.test is http://localhost:3333 but may be injected via 36 + // APP_URL env var by the test runner. We just check the prefix. 37 + assert.match(serviceDid(), /^did:web:/) 38 + }) 39 + 40 + test('publisherDid reads FEED_PUBLISHER_DID and returns null when empty', ({ assert }) => { 41 + const original = process.env.FEED_PUBLISHER_DID 42 + try { 43 + process.env.FEED_PUBLISHER_DID = 'did:plc:pub' 44 + assert.equal(publisherDid(), 'did:plc:pub') 45 + 46 + delete process.env.FEED_PUBLISHER_DID 47 + assert.isNull(publisherDid()) 48 + 49 + process.env.FEED_PUBLISHER_DID = '' 50 + assert.isNull(publisherDid()) 51 + } finally { 52 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 53 + else process.env.FEED_PUBLISHER_DID = original 54 + } 55 + }) 56 + 57 + test('feedUri composes at://<pub>/app.bsky.feed.generator/<rkey>', ({ assert }) => { 58 + const original = process.env.FEED_PUBLISHER_DID 59 + try { 60 + process.env.FEED_PUBLISHER_DID = 'did:plc:pub' 61 + assert.equal(feedUri('top-1k'), 'at://did:plc:pub/app.bsky.feed.generator/top-1k') 62 + 63 + delete process.env.FEED_PUBLISHER_DID 64 + assert.isNull(feedUri('top-1k')) 65 + } finally { 66 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 67 + else process.env.FEED_PUBLISHER_DID = original 68 + } 69 + }) 70 + 71 + test('findFeedByUri matches known feeds and rejects foreign publishers', ({ assert }) => { 72 + const original = process.env.FEED_PUBLISHER_DID 73 + try { 74 + process.env.FEED_PUBLISHER_DID = PUBLISHER 75 + assert.equal( 76 + findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/top-1k`)?.threshold, 77 + 1000 78 + ) 79 + assert.equal( 80 + findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/top-10k`)?.threshold, 81 + 10_000 82 + ) 83 + // Wrong publisher DID 84 + assert.isNull(findFeedByUri('at://did:plc:someoneelse/app.bsky.feed.generator/top-1k')) 85 + // Wrong collection 86 + assert.isNull(findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.post/top-1k`)) 87 + // Unknown rkey 88 + assert.isNull(findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/top-1m`)) 89 + // Malformed URI 90 + assert.isNull(findFeedByUri('not-an-at-uri')) 91 + } finally { 92 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 93 + else process.env.FEED_PUBLISHER_DID = original 94 + } 95 + }) 96 + 97 + test('didDocument declares a BskyFeedGenerator service', ({ assert }) => { 98 + const doc = didDocument() as { 99 + 'id': string 100 + '@context': string[] 101 + 'service': Array<{ id: string; type: string; serviceEndpoint: string }> 102 + } 103 + assert.match(doc.id, /^did:web:/) 104 + assert.include(doc['@context'], 'https://www.w3.org/ns/did/v1') 105 + const svc = doc.service.find((s) => s.type === 'BskyFeedGenerator') 106 + assert.isDefined(svc) 107 + assert.equal(svc!.id, '#bsky_fg') 108 + assert.isString(svc!.serviceEndpoint) 109 + }) 110 + }) 111 + 112 + test.group('feed_generator / getSkeleton', (group) => { 113 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 114 + 115 + const feedUri1k = `at://${PUBLISHER}/app.bsky.feed.generator/top-1k` 116 + const feedUri10k = `at://${PUBLISHER}/app.bsky.feed.generator/top-10k` 117 + 118 + group.each.setup(() => { 119 + const original = process.env.FEED_PUBLISHER_DID 120 + process.env.FEED_PUBLISHER_DID = PUBLISHER 121 + return () => { 122 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 123 + else process.env.FEED_PUBLISHER_DID = original 124 + } 125 + }) 126 + 127 + test('throws UnknownFeedError for an unrecognised feed URI', async ({ assert }) => { 128 + await assert.rejects( 129 + () => getSkeleton({ feedUri: 'at://did:plc:nope/app.bsky.feed.generator/x', limit: 50 }), 130 + UnknownFeedError 131 + ) 132 + }) 133 + 134 + test('returns entries for the matching threshold, newest-crossing first', async ({ assert }) => { 135 + const now = Date.now() 136 + await NotifiedThreshold.create({ 137 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/older', 138 + threshold: 1000, 139 + firedAt: now - 2000, 140 + }) 141 + await NotifiedThreshold.create({ 142 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/newer', 143 + threshold: 1000, 144 + firedAt: now - 1000, 145 + }) 146 + await NotifiedThreshold.create({ 147 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/tenk', 148 + threshold: 10_000, 149 + firedAt: now, 150 + }) 151 + 152 + const result = await getSkeleton({ feedUri: feedUri1k, limit: 50 }) 153 + assert.deepEqual( 154 + result.feed.map((f) => f.post), 155 + ['at://did:plc:a/app.bsky.feed.post/newer', 'at://did:plc:a/app.bsky.feed.post/older'] 156 + ) 157 + assert.isUndefined(result.cursor) 158 + }) 159 + 160 + test('filters by threshold — top-10k excludes 1k-only crossings', async ({ assert }) => { 161 + await NotifiedThreshold.create({ 162 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/one-k', 163 + threshold: 1000, 164 + firedAt: Date.now(), 165 + }) 166 + await NotifiedThreshold.create({ 167 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/ten-k', 168 + threshold: 10_000, 169 + firedAt: Date.now(), 170 + }) 171 + 172 + const result = await getSkeleton({ feedUri: feedUri10k, limit: 50 }) 173 + assert.deepEqual( 174 + result.feed.map((f) => f.post), 175 + ['at://did:plc:a/app.bsky.feed.post/ten-k'] 176 + ) 177 + }) 178 + 179 + test('paginates with a keyset cursor', async ({ assert }) => { 180 + const baseTs = Date.now() 181 + for (let i = 0; i < 5; i++) { 182 + await NotifiedThreshold.create({ 183 + // rkey = 0..4 with leading zero so lexicographic ordering matches numeric 184 + subjectUri: `at://did:plc:a/app.bsky.feed.post/p${i}`, 185 + threshold: 1000, 186 + firedAt: baseTs - i * 1000, 187 + }) 188 + } 189 + 190 + const first = await getSkeleton({ feedUri: feedUri1k, limit: 2 }) 191 + assert.lengthOf(first.feed, 2) 192 + assert.isString(first.cursor) 193 + assert.deepEqual( 194 + first.feed.map((f) => f.post), 195 + ['at://did:plc:a/app.bsky.feed.post/p0', 'at://did:plc:a/app.bsky.feed.post/p1'] 196 + ) 197 + 198 + const second = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: first.cursor }) 199 + assert.lengthOf(second.feed, 2) 200 + assert.deepEqual( 201 + second.feed.map((f) => f.post), 202 + ['at://did:plc:a/app.bsky.feed.post/p2', 'at://did:plc:a/app.bsky.feed.post/p3'] 203 + ) 204 + 205 + const third = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: second.cursor }) 206 + assert.lengthOf(third.feed, 1) 207 + assert.deepEqual( 208 + third.feed.map((f) => f.post), 209 + ['at://did:plc:a/app.bsky.feed.post/p4'] 210 + ) 211 + assert.isUndefined(third.cursor) 212 + }) 213 + 214 + test('keyset cursor handles ties on fired_at via subject_uri tiebreaker', async ({ assert }) => { 215 + const sharedTs = Date.now() 216 + // Inserted out-of-order intentionally. 217 + await NotifiedThreshold.create({ 218 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/alpha', 219 + threshold: 1000, 220 + firedAt: sharedTs, 221 + }) 222 + await NotifiedThreshold.create({ 223 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/charlie', 224 + threshold: 1000, 225 + firedAt: sharedTs, 226 + }) 227 + await NotifiedThreshold.create({ 228 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/bravo', 229 + threshold: 1000, 230 + firedAt: sharedTs, 231 + }) 232 + 233 + const first = await getSkeleton({ feedUri: feedUri1k, limit: 2 }) 234 + // Order: fired_at DESC, subject_uri DESC. 235 + assert.deepEqual( 236 + first.feed.map((f) => f.post), 237 + ['at://did:plc:a/app.bsky.feed.post/charlie', 'at://did:plc:a/app.bsky.feed.post/bravo'] 238 + ) 239 + 240 + const second = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: first.cursor }) 241 + assert.deepEqual( 242 + second.feed.map((f) => f.post), 243 + ['at://did:plc:a/app.bsky.feed.post/alpha'] 244 + ) 245 + }) 246 + 247 + test('returns empty feed and no cursor when no rows match', async ({ assert }) => { 248 + const result = await getSkeleton({ feedUri: feedUri1k, limit: 50 }) 249 + assert.deepEqual(result.feed, []) 250 + assert.isUndefined(result.cursor) 251 + }) 252 + })