A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

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

fix(api): don't clobber good profile data on failed bsky fetch

getProfile's refreshProfile overwrote users.avatar/display_name with an
empty name and a CID-less avatar URL whenever the bsky profile fetch in
retrieveProfile failed (record stays {}), and published the garbage to
NATS. A single failed fetch permanently corrupted the row.

Now only write a field when the fetch actually produced it, preserving
existing DB values otherwise; build the avatar URL from the real CID and
mimeType extension; and serve the resolved user in presentation so the
response and DB agree.

+53 -21
+53 -21
apps/api/src/xrpc/app/rocksky/actor/getProfile.ts
··· 272 272 }); 273 273 }; 274 274 275 + // Derive avatar URL + displayName from the fetched bsky profile record. 276 + // Returns `undefined` for a field when the record didn't provide it (e.g. the 277 + // profile fetch in `retrieveProfile` failed and left `profileRecord` empty), so 278 + // callers can preserve existing good data instead of clobbering it. 279 + const resolveProfileFields = ( 280 + profile: Profile, 281 + ): { avatar?: string; displayName?: string } => { 282 + const displayName = _.get(profile, "profileRecord.value.displayName") as 283 + | string 284 + | undefined; 285 + const ref = _.get(profile, "profileRecord.value.avatar.ref") as 286 + | { toString(): string } 287 + | undefined; 288 + const cid = ref ? ref.toString() : ""; 289 + const ext = 290 + _.get(profile, "profileRecord.value.avatar.mimeType", "").split("/")[1] || 291 + "jpeg"; 292 + return { 293 + avatar: cid 294 + ? `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${cid}@${ext}` 295 + : undefined, 296 + displayName: displayName || undefined, 297 + }; 298 + }; 299 + 275 300 const refreshProfile = ([ 276 301 profile, 277 302 handle, ··· 289 314 ]) => { 290 315 return Effect.tryPromise({ 291 316 try: async () => { 317 + const fetched = resolveProfileFields(profile); 318 + 292 319 if (!profile.user) { 320 + // Brand new user: nothing to preserve. `avatar` is NOT NULL, so fall 321 + // back to "" when the fetch yielded no avatar — a later successful 322 + // getProfile will fill it in via the update path below. 293 323 await profile.ctx.db 294 324 .insert(tables.users) 295 325 .values({ 296 326 did: profile.did, 297 327 handle, 298 - avatar: `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${_.get(profile, "profileRecord.value.avatar.ref", "").toString()}@jpeg`, 299 - displayName: _.get(profile, "profileRecord.value.displayName", ""), 328 + avatar: fetched.avatar ?? "", 329 + displayName: fetched.displayName ?? null, 300 330 }) 301 331 .execute(); 302 332 const users = await profile.ctx.db ··· 321 351 ), 322 352 ); 323 353 } else { 324 - // Update existing user in background if handle or avatar or displayName changed 354 + // Existing user: only take avatar/displayName from the fetch when it 355 + // actually provided them. A failed or partial profile fetch must never 356 + // overwrite good data with an empty name or a CID-less avatar URL. 357 + const nextAvatar = fetched.avatar ?? profile.user.avatar; 358 + const nextDisplayName = 359 + fetched.displayName ?? profile.user.displayName; 360 + 325 361 if ( 326 362 profile.user.handle !== handle || 327 - profile.user.avatar !== 328 - `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${_.get(profile, "profileRecord.value.avatar.ref", "").toString()}@jpeg` || 329 - profile.user.displayName !== 330 - _.get(profile, "profileRecord.value.displayName") 363 + profile.user.avatar !== nextAvatar || 364 + profile.user.displayName !== nextDisplayName 331 365 ) { 332 366 profile.ctx.db 333 367 .update(tables.users) 334 368 .set({ 335 369 handle, 336 - avatar: `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${_.get(profile, "profileRecord.value.avatar.ref", "").toString()}@jpeg`, 337 - displayName: _.get( 338 - profile, 339 - "profileRecord.value.displayName", 340 - "", 341 - ), 370 + avatar: nextAvatar, 371 + displayName: nextDisplayName, 342 372 updatedAt: new Date(), 343 373 }) 344 374 .where(eq(tables.users.id, profile.user.id)) ··· 350 380 xata_id: profile.user.id, 351 381 did: profile.user.did, 352 382 handle, 353 - display_name: _.get( 354 - profile, 355 - "profileRecord.value.displayName", 356 - "", 357 - ), 358 - avatar: `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${_.get(profile, "profileRecord.value.avatar.ref", "").toString()}@jpeg`, 383 + display_name: nextDisplayName, 384 + avatar: nextAvatar, 359 385 xata_createdat: profile.user.createdAt.toISOString(), 360 386 xata_updatedat: new Date().toISOString(), 361 387 xata_version: (profile.user.xataVersion || 1) + 1, ··· 363 389 ), 364 390 ); 365 391 } 392 + 393 + // Reflect the resolved values back onto the in-memory user so the 394 + // response (built in `presentation`) is consistent with the DB. 395 + profile.user.handle = handle; 396 + profile.user.avatar = nextAvatar; 397 + profile.user.displayName = nextDisplayName; 366 398 } 367 399 368 400 return [ ··· 397 429 id: profile.user?.id, 398 430 did: profile.did, 399 431 handle, 400 - displayName: _.get(profile, "profileRecord.value.displayName"), 401 - avatar: `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${_.get(profile, "profileRecord.value.avatar.ref", "").toString()}@jpeg`, 432 + displayName: profile.user?.displayName ?? undefined, 433 + avatar: profile.user?.avatar || undefined, 402 434 createdAt: profile.user?.createdAt.toISOString(), 403 435 updatedAt: profile.user?.updatedAt.toISOString(), 404 436 spotifyUser: {