From 2f88f792c5560179e7353ba9ea34e00d826eec6e Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 28 Dec 2025 23:59:25 +0300 Subject: [PATCH] Add feedView types and cursor pagination Introduce feedItemView and feedView schemas and switch getFeed to use a string cursor instead of numeric offset. Update generated types, pkl/lexicon defs, and the XRPC handler to return FeedView (and add the hydrate step + axios import) Add feedView types and cursor pagination --- apps/api/lexicons/feed/defs.json | 21 ++++++ apps/api/lexicons/feed/getFeed.json | 19 ++--- apps/api/pkl/defs/feed/defs.pkl | 66 ++++++++++++------ apps/api/pkl/defs/feed/getFeed.pkl | 20 ++---- apps/api/src/lexicon/lexicons.ts | 40 +++++++---- .../lexicon/types/app/rocksky/feed/defs.ts | 35 ++++++++++ .../lexicon/types/app/rocksky/feed/getFeed.ts | 13 ++-- apps/api/src/xrpc/app/rocksky/feed/getFeed.ts | 69 +++++++++++++------ apps/feeds/src/lex/lexicons.ts | 33 ++++++--- .../src/lex/types/app/rocksky/feed/defs.ts | 31 +++++++++ .../src/lex/types/app/rocksky/feed/getFeed.ts | 8 +-- 11 files changed, 245 insertions(+), 110 deletions(-) diff --git a/apps/api/lexicons/feed/defs.json b/apps/api/lexicons/feed/defs.json index 60c5f460..73cab205 100644 --- a/apps/api/lexicons/feed/defs.json +++ b/apps/api/lexicons/feed/defs.json @@ -148,6 +148,27 @@ "format": "at-uri" } } + }, + "feedItemView": { + "type": "object", + "properties": { + "scrobble": { + "type": "ref", + "ref": "app.rocksky.scrobble.defs#scrobbleViewBasic" + } + } + }, + "feedView": { + "type": "object", + "properties": { + "feed": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.feed.defs#feedItemView" + } + } + } } } } diff --git a/apps/api/lexicons/feed/getFeed.json b/apps/api/lexicons/feed/getFeed.json index 7e9c1d1e..e7eb702d 100644 --- a/apps/api/lexicons/feed/getFeed.json +++ b/apps/api/lexicons/feed/getFeed.json @@ -21,26 +21,17 @@ "description": "The maximum number of scrobbles to return", "minimum": 1 }, - "offset": { - "type": "integer", - "description": "The offset for pagination", - "minimum": 0 + "cursor": { + "type": "string", + "description": "The cursor for pagination" } } }, "output": { "encoding": "application/json", "schema": { - "type": "object", - "properties": { - "scrobbles": { - "type": "array", - "items": { - "type": "ref", - "ref": "app.rocksky.scrobble.defs#scrobbleViewBasic" - } - } - } + "type": "ref", + "ref": "app.rocksky.feed.defs#feedView" } } } diff --git a/apps/api/pkl/defs/feed/defs.pkl b/apps/api/pkl/defs/feed/defs.pkl index 57088f38..f07564dc 100644 --- a/apps/api/pkl/defs/feed/defs.pkl +++ b/apps/api/pkl/defs/feed/defs.pkl @@ -1,21 +1,22 @@ -amends "../../schema/lexicon.pkl" +amends "../../schema/lexicon.pkl" lexicon = 1 id = "app.rocksky.feed.defs" defs = new Mapping { - ["searchResultsView"] = new ObjectType { + ["searchResultsView"] = new ObjectType { type = "object" - properties { + properties { ["hits"] = new Array { type = "array" items = new Union { type = "union" - refs = List( - "app.rocksky.song.defs#songViewBasic", - "app.rocksky.album.defs#albumViewBasic", - "app.rocksky.artist.defs#artistViewBasic", - "app.rocksky.playlist.defs#playlistViewBasic", - "app.rocksky.actor.defs#profileViewBasic" + refs = + List( + "app.rocksky.song.defs#songViewBasic", + "app.rocksky.album.defs#albumViewBasic", + "app.rocksky.artist.defs#artistViewBasic", + "app.rocksky.playlist.defs#playlistViewBasic", + "app.rocksky.actor.defs#profileViewBasic" ) } } @@ -32,7 +33,6 @@ defs = new Mapping { type = "integer" } } - } ["nowPlayingView"] = new ObjectType { type = "object" @@ -91,9 +91,9 @@ defs = new Mapping { } } } - ["nowPlayingsView"] = new ObjectType { + ["nowPlayingsView"] = new ObjectType { type = "object" - properties { + properties { ["nowPlayings"] = new Array { type = "array" items = new Ref { @@ -105,7 +105,7 @@ defs = new Mapping { } ["feedGeneratorsView"] = new ObjectType { type = "object" - properties { + properties { ["feeds"] = new Array { type = "array" items = new Ref { @@ -142,13 +142,35 @@ defs = new Mapping { } } ["feedUriView"] = new ObjectType { - type = "object" - properties { - ["uri"] = new StringType { - type = "string" - description = "The feed URI." - format = "at-uri" - } - } - } + type = "object" + properties { + ["uri"] = new StringType { + type = "string" + description = "The feed URI." + format = "at-uri" + } + } + } + + ["feedItemView"] = new ObjectType { + type = "object" + properties { + ["scrobble"] = new Ref { + ref = "app.rocksky.scrobble.defs#scrobbleViewBasic" + } + } + } + + ["feedView"] = new ObjectType { + type = "object" + properties { + ["feed"] = new Array { + type = "array" + items = new Ref { + type = "ref" + ref = "app.rocksky.feed.defs#feedItemView" + } + } + } + } } diff --git a/apps/api/pkl/defs/feed/getFeed.pkl b/apps/api/pkl/defs/feed/getFeed.pkl index 7fb54944..30084d69 100644 --- a/apps/api/pkl/defs/feed/getFeed.pkl +++ b/apps/api/pkl/defs/feed/getFeed.pkl @@ -20,25 +20,17 @@ defs = new Mapping { description = "The maximum number of scrobbles to return" minimum = 1 } - ["offset"] = new IntegerType { - type = "integer" - description = "The offset for pagination" - minimum = 0 + ["cursor"] = new StringType { + type = "string" + description = "The cursor for pagination" } } } output { encoding = "application/json" - schema = new ObjectType { - type = "object" - properties = new Mapping { - ["scrobbles"] = new Array { - type = "array" - items = new Ref { - ref = "app.rocksky.scrobble.defs#scrobbleViewBasic" - } - } - } + schema = new Ref { + type = "ref" + ref = "app.rocksky.feed.defs#feedView" } } } diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index 1e98d386..164169a4 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -2324,6 +2324,27 @@ export const schemaDict = { }, }, }, + feedItemView: { + type: "object", + properties: { + scrobble: { + type: "ref", + ref: "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", + }, + }, + }, + feedView: { + type: "object", + properties: { + feed: { + type: "array", + items: { + type: "ref", + ref: "lex:app.rocksky.feed.defs#feedItemView", + }, + }, + }, + }, }, }, AppRockskyFeedDescribeFeedGenerator: { @@ -2424,26 +2445,17 @@ export const schemaDict = { description: "The maximum number of scrobbles to return", minimum: 1, }, - offset: { - type: "integer", - description: "The offset for pagination", - minimum: 0, + cursor: { + type: "string", + description: "The cursor for pagination", }, }, }, output: { encoding: "application/json", schema: { - type: "object", - properties: { - scrobbles: { - type: "array", - items: { - type: "ref", - ref: "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", - }, - }, - }, + type: "ref", + ref: "lex:app.rocksky.feed.defs#feedView", }, }, }, diff --git a/apps/api/src/lexicon/types/app/rocksky/feed/defs.ts b/apps/api/src/lexicon/types/app/rocksky/feed/defs.ts index 44d471af..2ed99604 100644 --- a/apps/api/src/lexicon/types/app/rocksky/feed/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/feed/defs.ts @@ -10,6 +10,7 @@ import type * as AppRockskyAlbumDefs from "../album/defs"; import type * as AppRockskyArtistDefs from "../artist/defs"; import type * as AppRockskyPlaylistDefs from "../playlist/defs"; import type * as AppRockskyActorDefs from "../actor/defs"; +import type * as AppRockskyScrobbleDefs from "../scrobble/defs"; export interface SearchResultsView { hits?: ( @@ -143,3 +144,37 @@ export function isFeedUriView(v: unknown): v is FeedUriView { export function validateFeedUriView(v: unknown): ValidationResult { return lexicons.validate("app.rocksky.feed.defs#feedUriView", v); } + +export interface FeedItemView { + scrobble?: AppRockskyScrobbleDefs.ScrobbleViewBasic; + [k: string]: unknown; +} + +export function isFeedItemView(v: unknown): v is FeedItemView { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.feed.defs#feedItemView" + ); +} + +export function validateFeedItemView(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.feed.defs#feedItemView", v); +} + +export interface FeedView { + feed?: FeedItemView[]; + [k: string]: unknown; +} + +export function isFeedView(v: unknown): v is FeedView { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.feed.defs#feedView" + ); +} + +export function validateFeedView(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.feed.defs#feedView", v); +} diff --git a/apps/api/src/lexicon/types/app/rocksky/feed/getFeed.ts b/apps/api/src/lexicon/types/app/rocksky/feed/getFeed.ts index d2f58451..39b472de 100644 --- a/apps/api/src/lexicon/types/app/rocksky/feed/getFeed.ts +++ b/apps/api/src/lexicon/types/app/rocksky/feed/getFeed.ts @@ -7,24 +7,19 @@ import { lexicons } from "../../../../lexicons"; import { isObj, hasProp } from "../../../../util"; import { CID } from "multiformats/cid"; import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; -import type * as AppRockskyScrobbleDefs from "../scrobble/defs"; +import type * as AppRockskyFeedDefs from "./defs"; export interface QueryParams { /** The feed URI. */ feed: string; /** The maximum number of scrobbles to return */ limit?: number; - /** The offset for pagination */ - offset?: number; + /** The cursor for pagination */ + cursor?: string; } export type InputSchema = undefined; - -export interface OutputSchema { - scrobbles?: AppRockskyScrobbleDefs.ScrobbleViewBasic[]; - [k: string]: unknown; -} - +export type OutputSchema = AppRockskyFeedDefs.FeedView; export type HandlerInput = undefined; export interface HandlerSuccess { diff --git a/apps/api/src/xrpc/app/rocksky/feed/getFeed.ts b/apps/api/src/xrpc/app/rocksky/feed/getFeed.ts index 4e24f518..174dd69c 100644 --- a/apps/api/src/xrpc/app/rocksky/feed/getFeed.ts +++ b/apps/api/src/xrpc/app/rocksky/feed/getFeed.ts @@ -1,20 +1,22 @@ import type { Context } from "context"; -import { desc, eq } from "drizzle-orm"; +import { desc, eq, inArray } from "drizzle-orm"; import { Effect, pipe } from "effect"; import type { Server } from "lexicon"; -import type { ScrobbleViewBasic } from "lexicon/types/app/rocksky/scrobble/defs"; import type { QueryParams } from "lexicon/types/app/rocksky/feed/getFeed"; +import type { FeedView } from "lexicon/types/app/rocksky/feed/defs"; import * as R from "ramda"; import tables from "schema"; import type { SelectScrobble } from "schema/scrobbles"; import type { SelectTrack } from "schema/tracks"; import type { SelectUser } from "schema/users"; +import axios from "axios"; export default function (server: Server, ctx: Context) { const getFeed = (params: QueryParams) => pipe( { params, ctx }, retrieve, + Effect.flatMap(hydrate), Effect.flatMap(presentation), Effect.retry({ times: 3 }), Effect.timeout("10 seconds"), @@ -34,11 +36,37 @@ export default function (server: Server, ctx: Context) { }); } -const retrieve = ({ - params, +const retrieve = ({ params, ctx }: { params: QueryParams; ctx: Context }) => { + return Effect.tryPromise({ + try: async () => { + const [feed] = await ctx.db + .select() + .from(tables.feeds) + .where(eq(tables.feeds.uri, params.feed)) + .execute(); + if (!feed) { + throw new Error(`Feed not found`); + } + const feedUrl = `https://${feed.did.split("did:web:")[1]}`; + const response = await axios.get<{ + cusrsor: string; + feed: { scrobble: string }[]; + }>(`${feedUrl}/xrpc/app.rocksky.feed.getFeedSkeleton`, { + params: { + feed: feed.uri, + }, + }); + return { uris: response.data.feed.map(({ scrobble }) => scrobble), ctx }; + }, + catch: (error) => new Error(`Failed to retrieve feed: ${error}`), + }); +}; + +const hydrate = ({ + uris, ctx, }: { - params: QueryParams; + uris: string[]; ctx: Context; }): Effect.Effect => { return Effect.tryPromise({ @@ -48,29 +76,28 @@ const retrieve = ({ .from(tables.scrobbles) .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) + .where(inArray(tables.scrobbles.uri, uris)) .orderBy(desc(tables.scrobbles.timestamp)) - .offset(params.offset || 0) - .limit(params.limit || 20) .execute(), - catch: (error) => new Error(`Failed to retrieve scrobbles: ${error}`), + catch: (error) => new Error(`Failed to hydrate feed: ${error}`), }); }; -const presentation = ( - data: Scrobbles, -): Effect.Effect<{ scrobbles: ScrobbleViewBasic[] }, never> => { +const presentation = (data: Scrobbles): Effect.Effect => { return Effect.sync(() => ({ - scrobbles: data.map(({ scrobbles, tracks, users }) => ({ - ...R.omit(["albumArt", "id", "lyrics"])(tracks), - cover: tracks.albumArt, - date: scrobbles.timestamp.toISOString(), - user: users.handle, - userDisplayName: users.displayName, - userAvatar: users.avatar, - uri: scrobbles.uri, - tags: [], - id: scrobbles.id, + feed: data.map(({ scrobbles, tracks, users }) => ({ + scrobble: { + ...R.omit(["albumArt", "id", "lyrics"])(tracks), + cover: tracks.albumArt, + date: scrobbles.timestamp.toISOString(), + user: users.handle, + userDisplayName: users.displayName, + userAvatar: users.avatar, + uri: scrobbles.uri, + tags: [], + id: scrobbles.id, + }, })), })); }; diff --git a/apps/feeds/src/lex/lexicons.ts b/apps/feeds/src/lex/lexicons.ts index 6986bc9c..10e78c99 100644 --- a/apps/feeds/src/lex/lexicons.ts +++ b/apps/feeds/src/lex/lexicons.ts @@ -2780,6 +2780,27 @@ export const schemaDict = { }, }, }, + "feedItemView": { + "type": "object", + "properties": { + "scrobble": { + "type": "ref", + "ref": "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", + }, + }, + }, + "feedView": { + "type": "object", + "properties": { + "feed": { + "type": "array", + "items": { + "type": "ref", + "ref": "lex:app.rocksky.feed.defs#feedItemView", + }, + }, + }, + }, }, }, "AppRockskyFeedGetFeedGenerators": { @@ -2970,16 +2991,8 @@ export const schemaDict = { "output": { "encoding": "application/json", "schema": { - "type": "object", - "properties": { - "scrobbles": { - "type": "array", - "items": { - "type": "ref", - "ref": "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", - }, - }, - }, + "type": "ref", + "ref": "lex:app.rocksky.feed.defs#feedView", }, }, }, diff --git a/apps/feeds/src/lex/types/app/rocksky/feed/defs.ts b/apps/feeds/src/lex/types/app/rocksky/feed/defs.ts index 71d7c577..5209b1c9 100644 --- a/apps/feeds/src/lex/types/app/rocksky/feed/defs.ts +++ b/apps/feeds/src/lex/types/app/rocksky/feed/defs.ts @@ -8,6 +8,7 @@ import type * as AppRockskyAlbumDefs from "../album/defs.ts"; import type * as AppRockskyArtistDefs from "../artist/defs.ts"; import type * as AppRockskyPlaylistDefs from "../playlist/defs.ts"; import type * as AppRockskyActorDefs from "../actor/defs.ts"; +import type * as AppRockskyScrobbleDefs from "../scrobble/defs.ts"; const is$typed = _is$typed, validate = _validate; const id = "app.rocksky.feed.defs"; @@ -132,3 +133,33 @@ export function isFeedUriView(v: V) { export function validateFeedUriView(v: V) { return validate(v, id, hashFeedUriView); } + +export interface FeedItemView { + $type?: "app.rocksky.feed.defs#feedItemView"; + scrobble?: AppRockskyScrobbleDefs.ScrobbleViewBasic; +} + +const hashFeedItemView = "feedItemView"; + +export function isFeedItemView(v: V) { + return is$typed(v, id, hashFeedItemView); +} + +export function validateFeedItemView(v: V) { + return validate(v, id, hashFeedItemView); +} + +export interface FeedView { + $type?: "app.rocksky.feed.defs#feedView"; + feed?: (FeedItemView)[]; +} + +const hashFeedView = "feedView"; + +export function isFeedView(v: V) { + return is$typed(v, id, hashFeedView); +} + +export function validateFeedView(v: V) { + return validate(v, id, hashFeedView); +} diff --git a/apps/feeds/src/lex/types/app/rocksky/feed/getFeed.ts b/apps/feeds/src/lex/types/app/rocksky/feed/getFeed.ts index 0d657094..ac215ef6 100644 --- a/apps/feeds/src/lex/types/app/rocksky/feed/getFeed.ts +++ b/apps/feeds/src/lex/types/app/rocksky/feed/getFeed.ts @@ -1,7 +1,7 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import type * as AppRockskyScrobbleDefs from "../scrobble/defs.ts"; +import type * as AppRockskyFeedDefs from "./defs.ts"; export type QueryParams = { /** The feed URI. */ @@ -12,11 +12,7 @@ export type QueryParams = { offset?: number; }; export type InputSchema = undefined; - -export interface OutputSchema { - scrobbles?: (AppRockskyScrobbleDefs.ScrobbleViewBasic)[]; -} - +export type OutputSchema = AppRockskyFeedDefs.FeedView; export type HandlerInput = void; export interface HandlerSuccess { -- 2.51.2