See the best posts from any Bluesky account
0
fork

Configure Feed

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

Merge pull request #2 from taobojlen/claude/bluesky-custom-feeds-eVSAI

Add Bluesky custom feed generator endpoints

authored by

Tao Bojlén and committed by
GitHub
6a59cde9 1b2db53e

+1000
+1
.adonisjs/server/controllers.ts
··· 5 5 6 6 export const controllers = { 7 7 Engagement: () => import('#controllers/engagement_controller'), 8 + FeedGenerator: () => import('#controllers/feed_generator_controller'), 8 9 HealthChecks: () => import('#controllers/health_checks_controller'), 9 10 Landing: () => import('#controllers/landing_controller'), 10 11 Oauth: () => import('#controllers/oauth_controller'),
+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 + * 1k-favs — posts that have crossed 1,000 likes 9 + * 10k-favs — 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: '1k-favs', 29 + threshold: 1000, 30 + displayName: 'favs.blue 1000 ❤️s', 31 + description: 'All bluesky posts with over 1000 likes, powered by https://favs.blue', 32 + }, 33 + { 34 + rkey: '10k-favs', 35 + threshold: 10_000, 36 + displayName: 'favs.blue 10,000 ❤️s', 37 + description: 'All bluesky posts with over 10,000 likes, powered by https://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 + }
+128
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 --handle <handle> --password <app-password>` 8 + * 9 + * Publishes `app.bsky.feed.generator` records on an atproto account's PDS for 10 + * each configured feed (1k-favs, 10k-favs). This is a one-shot bootstrap step 11 + * that makes the feeds discoverable in Bluesky clients. 12 + * 13 + * Flags: 14 + * --handle handle that will own the feed records (required) 15 + * --password app password for that account (required) 16 + * --pds PDS service URL (default: https://bsky.social) 17 + * --dry-run print what would be published without writing 18 + * 19 + * After publishing, the command logs the account's DID. Set 20 + * FEED_PUBLISHER_DID=<that DID> in the web process's env so 21 + * describeFeedGenerator advertises the feeds. 22 + * 23 + * The service DID served at the generator endpoint is derived from APP_URL 24 + * (did:web:<host>) — the feed record points to that DID, not the publisher. 25 + * 26 + * Re-running the command is safe: putRecord is idempotent on (repo, 27 + * collection, rkey). 28 + */ 29 + export default class FeedsPublish extends BaseCommand { 30 + static commandName = 'feeds:publish' 31 + static description = 'Publish app.bsky.feed.generator records for 1k-favs and 10k-favs' 32 + static options: CommandOptions = { startApp: true } 33 + 34 + @flags.string({ description: 'Handle of the account that will own the feed records' }) 35 + declare handle: string 36 + 37 + @flags.string({ description: 'App password for the publisher account' }) 38 + declare password: string 39 + 40 + @flags.string({ description: 'PDS service URL', default: 'https://bsky.social' }) 41 + declare pds: string 42 + 43 + @flags.boolean({ description: "Don't write records — print what would be published" }) 44 + declare dryRun: boolean 45 + 46 + async run() { 47 + if (!this.handle || !this.password) { 48 + this.logger.error('--handle and --password are required') 49 + this.exitCode = 1 50 + return 51 + } 52 + 53 + const svcDid = serviceDid() 54 + this.logger.info(`Service DID (will be set on each feed record): ${svcDid}`) 55 + 56 + const agent = new AtpAgent({ service: this.pds }) 57 + 58 + try { 59 + await agent.login({ identifier: this.handle, password: this.password }) 60 + } catch (err) { 61 + this.logger.error(`Login failed: ${err instanceof Error ? err.message : String(err)}`) 62 + this.exitCode = 1 63 + return 64 + } 65 + 66 + const accountDid = agent.session?.did 67 + if (!accountDid) { 68 + this.logger.error('Login succeeded but no session DID returned') 69 + this.exitCode = 1 70 + return 71 + } 72 + 73 + this.logger.info(`Authenticated as ${this.handle} (${accountDid})`) 74 + 75 + const expected = publisherDid() 76 + if (expected && expected !== accountDid) { 77 + this.logger.error( 78 + `FEED_PUBLISHER_DID (${expected}) does not match the account DID (${accountDid}). ` + 79 + 'Refusing to publish under the wrong account.' 80 + ) 81 + this.exitCode = 1 82 + return 83 + } 84 + if (!expected) { 85 + this.logger.warning( 86 + `FEED_PUBLISHER_DID is not set. After publishing, set FEED_PUBLISHER_DID=${accountDid} ` + 87 + 'so the web process can advertise these feeds in describeFeedGenerator.' 88 + ) 89 + } 90 + 91 + const createdAt = new Date().toISOString() 92 + for (const feed of FEEDS) { 93 + const record = { 94 + $type: 'app.bsky.feed.generator', 95 + did: svcDid, 96 + displayName: feed.displayName, 97 + description: feed.description, 98 + createdAt, 99 + } 100 + 101 + if (this.dryRun) { 102 + this.logger.info(`[dry-run] Would putRecord rkey=${feed.rkey}:`) 103 + this.logger.info(JSON.stringify(record, null, 2)) 104 + continue 105 + } 106 + 107 + try { 108 + const result = await agent.com.atproto.repo.putRecord({ 109 + repo: accountDid, 110 + collection: 'app.bsky.feed.generator', 111 + rkey: feed.rkey, 112 + record, 113 + }) 114 + this.logger.info(`Published ${feed.rkey} → ${result.data.uri}`) 115 + } catch (err) { 116 + this.logger.error( 117 + `Failed to publish ${feed.rkey}: ${err instanceof Error ? err.message : String(err)}` 118 + ) 119 + this.exitCode = 1 120 + return 121 + } 122 + } 123 + 124 + if (!this.dryRun) { 125 + this.logger.info('All feeds published.') 126 + } 127 + } 128 + }
+37
database/migrations/1776300000000_index_notified_thresholds_for_feeds.ts
··· 1 + import { BaseSchema } from '@adonisjs/lucid/schema' 2 + 3 + /** 4 + * Composite index covering the feed-generator's getFeedSkeleton query: 5 + * 6 + * SELECT subject_uri 7 + * FROM notified_thresholds 8 + * WHERE threshold = ? 9 + * AND (fired_at < ? OR (fired_at = ? AND subject_uri < ?)) -- cursor 10 + * ORDER BY fired_at DESC, subject_uri DESC 11 + * LIMIT ? 12 + * 13 + * Without this index the table is scanned in full because the existing PK 14 + * (subject_uri, threshold) has subject_uri leftmost, which can't service a 15 + * threshold-only filter. SQLite can walk an ASC composite index in reverse 16 + * for the DESC ORDER BY, so this index handles both the filter and the sort 17 + * in a single index range scan. 18 + */ 19 + export default class extends BaseSchema { 20 + async up() { 21 + this.schema.alterTable('notified_thresholds', (table) => { 22 + table.index( 23 + ['threshold', 'fired_at', 'subject_uri'], 24 + 'notified_thresholds_threshold_fired_at_subject_uri' 25 + ) 26 + }) 27 + } 28 + 29 + async down() { 30 + this.schema.alterTable('notified_thresholds', (table) => { 31 + table.dropIndex( 32 + ['threshold', 'fired_at', 'subject_uri'], 33 + 'notified_thresholds_threshold_fired_at_subject_uri' 34 + ) 35 + }) 36 + } 37 + }
+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 1k-favs and 10k-favs', 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/10k-favs`, 60 + `at://${PUBLISHER}/app.bsky.feed.generator/1k-favs`, 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 1k-favs 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/1k-favs` 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 10k-favs 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/10k-favs` 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/1k-favs`) 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/1k-favs`) 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 + })
+279
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 db from '@adonisjs/lucid/services/db' 13 + import NotifiedThreshold from '#models/notified_threshold' 14 + import { 15 + FEEDS, 16 + UnknownFeedError, 17 + didDocument, 18 + feedUri, 19 + findFeedByUri, 20 + getSkeleton, 21 + publisherDid, 22 + serviceDid, 23 + } from '#services/feed_generator' 24 + 25 + const PUBLISHER = 'did:plc:testpublisher' 26 + 27 + test.group('feed_generator / config', () => { 28 + test('exports exactly the two required feeds', ({ assert }) => { 29 + const rkeys = FEEDS.map((f) => f.rkey).sort() 30 + assert.deepEqual(rkeys, ['10k-favs', '1k-favs']) 31 + const thresholds = FEEDS.map((f) => f.threshold).sort((a, b) => a - b) 32 + assert.deepEqual(thresholds, [1000, 10_000]) 33 + }) 34 + 35 + test('serviceDid derives did:web from APP_URL hostname', ({ assert }) => { 36 + // APP_URL in .env.test is http://localhost:3333 but may be injected via 37 + // APP_URL env var by the test runner. We just check the prefix. 38 + assert.match(serviceDid(), /^did:web:/) 39 + }) 40 + 41 + test('publisherDid reads FEED_PUBLISHER_DID and returns null when empty', ({ assert }) => { 42 + const original = process.env.FEED_PUBLISHER_DID 43 + try { 44 + process.env.FEED_PUBLISHER_DID = 'did:plc:pub' 45 + assert.equal(publisherDid(), 'did:plc:pub') 46 + 47 + delete process.env.FEED_PUBLISHER_DID 48 + assert.isNull(publisherDid()) 49 + 50 + process.env.FEED_PUBLISHER_DID = '' 51 + assert.isNull(publisherDid()) 52 + } finally { 53 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 54 + else process.env.FEED_PUBLISHER_DID = original 55 + } 56 + }) 57 + 58 + test('feedUri composes at://<pub>/app.bsky.feed.generator/<rkey>', ({ assert }) => { 59 + const original = process.env.FEED_PUBLISHER_DID 60 + try { 61 + process.env.FEED_PUBLISHER_DID = 'did:plc:pub' 62 + assert.equal(feedUri('1k-favs'), 'at://did:plc:pub/app.bsky.feed.generator/1k-favs') 63 + 64 + delete process.env.FEED_PUBLISHER_DID 65 + assert.isNull(feedUri('1k-favs')) 66 + } finally { 67 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 68 + else process.env.FEED_PUBLISHER_DID = original 69 + } 70 + }) 71 + 72 + test('findFeedByUri matches known feeds and rejects foreign publishers', ({ assert }) => { 73 + const original = process.env.FEED_PUBLISHER_DID 74 + try { 75 + process.env.FEED_PUBLISHER_DID = PUBLISHER 76 + assert.equal( 77 + findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/1k-favs`)?.threshold, 78 + 1000 79 + ) 80 + assert.equal( 81 + findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/10k-favs`)?.threshold, 82 + 10_000 83 + ) 84 + // Wrong publisher DID 85 + assert.isNull(findFeedByUri('at://did:plc:someoneelse/app.bsky.feed.generator/1k-favs')) 86 + // Wrong collection 87 + assert.isNull(findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.post/1k-favs`)) 88 + // Unknown rkey 89 + assert.isNull(findFeedByUri(`at://${PUBLISHER}/app.bsky.feed.generator/1m-favs`)) 90 + // Malformed URI 91 + assert.isNull(findFeedByUri('not-an-at-uri')) 92 + } finally { 93 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 94 + else process.env.FEED_PUBLISHER_DID = original 95 + } 96 + }) 97 + 98 + test('didDocument declares a BskyFeedGenerator service', ({ assert }) => { 99 + const doc = didDocument() as { 100 + 'id': string 101 + '@context': string[] 102 + 'service': Array<{ id: string; type: string; serviceEndpoint: string }> 103 + } 104 + assert.match(doc.id, /^did:web:/) 105 + assert.include(doc['@context'], 'https://www.w3.org/ns/did/v1') 106 + const svc = doc.service.find((s) => s.type === 'BskyFeedGenerator') 107 + assert.isDefined(svc) 108 + assert.equal(svc!.id, '#bsky_fg') 109 + assert.isString(svc!.serviceEndpoint) 110 + }) 111 + }) 112 + 113 + test.group('feed_generator / getSkeleton', (group) => { 114 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 115 + 116 + const feedUri1k = `at://${PUBLISHER}/app.bsky.feed.generator/1k-favs` 117 + const feedUri10k = `at://${PUBLISHER}/app.bsky.feed.generator/10k-favs` 118 + 119 + group.each.setup(() => { 120 + const original = process.env.FEED_PUBLISHER_DID 121 + process.env.FEED_PUBLISHER_DID = PUBLISHER 122 + return () => { 123 + if (original === undefined) delete process.env.FEED_PUBLISHER_DID 124 + else process.env.FEED_PUBLISHER_DID = original 125 + } 126 + }) 127 + 128 + test('throws UnknownFeedError for an unrecognised feed URI', async ({ assert }) => { 129 + await assert.rejects( 130 + () => getSkeleton({ feedUri: 'at://did:plc:nope/app.bsky.feed.generator/x', limit: 50 }), 131 + UnknownFeedError 132 + ) 133 + }) 134 + 135 + test('returns entries for the matching threshold, newest-crossing first', async ({ assert }) => { 136 + const now = Date.now() 137 + await NotifiedThreshold.create({ 138 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/older', 139 + threshold: 1000, 140 + firedAt: now - 2000, 141 + }) 142 + await NotifiedThreshold.create({ 143 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/newer', 144 + threshold: 1000, 145 + firedAt: now - 1000, 146 + }) 147 + await NotifiedThreshold.create({ 148 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/tenk', 149 + threshold: 10_000, 150 + firedAt: now, 151 + }) 152 + 153 + const result = await getSkeleton({ feedUri: feedUri1k, limit: 50 }) 154 + assert.deepEqual( 155 + result.feed.map((f) => f.post), 156 + ['at://did:plc:a/app.bsky.feed.post/newer', 'at://did:plc:a/app.bsky.feed.post/older'] 157 + ) 158 + assert.isUndefined(result.cursor) 159 + }) 160 + 161 + test('filters by threshold — 10k-favs excludes 1k-only crossings', async ({ assert }) => { 162 + await NotifiedThreshold.create({ 163 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/one-k', 164 + threshold: 1000, 165 + firedAt: Date.now(), 166 + }) 167 + await NotifiedThreshold.create({ 168 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/ten-k', 169 + threshold: 10_000, 170 + firedAt: Date.now(), 171 + }) 172 + 173 + const result = await getSkeleton({ feedUri: feedUri10k, limit: 50 }) 174 + assert.deepEqual( 175 + result.feed.map((f) => f.post), 176 + ['at://did:plc:a/app.bsky.feed.post/ten-k'] 177 + ) 178 + }) 179 + 180 + test('paginates with a keyset cursor', async ({ assert }) => { 181 + const baseTs = Date.now() 182 + for (let i = 0; i < 5; i++) { 183 + await NotifiedThreshold.create({ 184 + // rkey = 0..4 with leading zero so lexicographic ordering matches numeric 185 + subjectUri: `at://did:plc:a/app.bsky.feed.post/p${i}`, 186 + threshold: 1000, 187 + firedAt: baseTs - i * 1000, 188 + }) 189 + } 190 + 191 + const first = await getSkeleton({ feedUri: feedUri1k, limit: 2 }) 192 + assert.lengthOf(first.feed, 2) 193 + assert.isString(first.cursor) 194 + assert.deepEqual( 195 + first.feed.map((f) => f.post), 196 + ['at://did:plc:a/app.bsky.feed.post/p0', 'at://did:plc:a/app.bsky.feed.post/p1'] 197 + ) 198 + 199 + const second = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: first.cursor }) 200 + assert.lengthOf(second.feed, 2) 201 + assert.deepEqual( 202 + second.feed.map((f) => f.post), 203 + ['at://did:plc:a/app.bsky.feed.post/p2', 'at://did:plc:a/app.bsky.feed.post/p3'] 204 + ) 205 + 206 + const third = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: second.cursor }) 207 + assert.lengthOf(third.feed, 1) 208 + assert.deepEqual( 209 + third.feed.map((f) => f.post), 210 + ['at://did:plc:a/app.bsky.feed.post/p4'] 211 + ) 212 + assert.isUndefined(third.cursor) 213 + }) 214 + 215 + test('keyset cursor handles ties on fired_at via subject_uri tiebreaker', async ({ assert }) => { 216 + const sharedTs = Date.now() 217 + // Inserted out-of-order intentionally. 218 + await NotifiedThreshold.create({ 219 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/alpha', 220 + threshold: 1000, 221 + firedAt: sharedTs, 222 + }) 223 + await NotifiedThreshold.create({ 224 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/charlie', 225 + threshold: 1000, 226 + firedAt: sharedTs, 227 + }) 228 + await NotifiedThreshold.create({ 229 + subjectUri: 'at://did:plc:a/app.bsky.feed.post/bravo', 230 + threshold: 1000, 231 + firedAt: sharedTs, 232 + }) 233 + 234 + const first = await getSkeleton({ feedUri: feedUri1k, limit: 2 }) 235 + // Order: fired_at DESC, subject_uri DESC. 236 + assert.deepEqual( 237 + first.feed.map((f) => f.post), 238 + ['at://did:plc:a/app.bsky.feed.post/charlie', 'at://did:plc:a/app.bsky.feed.post/bravo'] 239 + ) 240 + 241 + const second = await getSkeleton({ feedUri: feedUri1k, limit: 2, cursor: first.cursor }) 242 + assert.deepEqual( 243 + second.feed.map((f) => f.post), 244 + ['at://did:plc:a/app.bsky.feed.post/alpha'] 245 + ) 246 + }) 247 + 248 + test('returns empty feed and no cursor when no rows match', async ({ assert }) => { 249 + const result = await getSkeleton({ feedUri: feedUri1k, limit: 50 }) 250 + assert.deepEqual(result.feed, []) 251 + assert.isUndefined(result.cursor) 252 + }) 253 + 254 + test('skeleton query uses the threshold/fired_at index instead of a scan', async ({ 255 + assert, 256 + }) => { 257 + // Verifies the migration-created composite index is actually picked by 258 + // SQLite's planner for the page-1 query shape. If someone drops the index 259 + // or changes the query shape into something that can't use it, this test 260 + // catches it instead of silently regressing to a full table scan. 261 + const plan = (await db 262 + .connection() 263 + .knexRawQuery( 264 + 'EXPLAIN QUERY PLAN SELECT subject_uri FROM notified_thresholds ' + 265 + 'WHERE threshold = ? ORDER BY fired_at DESC, subject_uri DESC LIMIT 50', 266 + [1000] 267 + )) as Array<{ detail: string }> 268 + 269 + const detail = plan.map((row) => row.detail).join(' | ') 270 + // SQLite reports either "USING INDEX <name>" or "USING COVERING INDEX <name>"; 271 + // the latter means the index alone satisfies the query (no table fetch). 272 + assert.match( 273 + detail, 274 + /USING (?:COVERING )?INDEX notified_thresholds_threshold_fired_at_subject_uri/, 275 + `expected planner to use the composite index, got: ${detail}` 276 + ) 277 + assert.notMatch(detail, /SCAN notified_thresholds\b/, 'planner should not full-scan the table') 278 + }) 279 + })