Skip to content

Conversation

@fatfingers23
Copy link
Contributor

@fatfingers23 fatfingers23 commented Feb 2, 2026

Our first social feature 🥳 This closes #79

Adds

  • Likes and unlikes
  • Generic cache that uses useStorage locally and redis in prod with set expire
  • Caches on the Constellation requests to return total counts faster since Constellation cannot show counts until it indexs it and that takes a few milliseconds to seconds depending on traffic
  • Some more helpers for working with atproto for checking for scopes, redirecting to login
  • Since we have added a scope the client needs to re-authenticate with the new one if a user was already logged in. Right now if it sees the logged in client does not have the new scope it directs to the PDS to reauth.
  • If the user likes without being logged in shows them the auth modal

Could be Improved

  • Not sure on the icon. Went with what I could find. I think a filled one may work better and an animation would be nice.
  • I think the caching layer I added for Likes could be abstracted out for better use with constellation. Constellation is the source of truth, but we need caching at times for faster feed back. Will have to think on how that will work
  • The redirect for scopes reauth could probably be improved to let the user know?
  • Auth always redirects back to main page. I think we could probably add an item to add some state where on redirect it loads the previous page

Concerns

  • Race conditions on likes and many users. Interested to see how it holds up with a lot of users at once. And just genreal race condtions of state
  • I'm not familiar with how Nuxt handles dependencies and not sure if the way I did the Redis connection will reuse client connections or open new ones?
  • I need to test more on error handling on everything and showing errors from the API. But this PR was getting really long in the tooth and I think some of that can be divided up for others who may not know atproto stuff but are much better with Nuxt than me

I probably won't be able to address feedback till tomorrow around 22:00 UTC

Zen.mp4

@vercel
Copy link

vercel bot commented Feb 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 3, 2026 2:35pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 3, 2026 2:35pm
npmx-lunaria Ignored Ignored Feb 3, 2026 2:35pm

Request Review

Copy link
Contributor

@Adebesin-Cell Adebesin-Cell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting PR!

Screen.Recording.2026-02-02.at.08.07.12.mov

Connecting with any of the socials results in a 404 error.

It attempts to navigate to: https://npmxdev-git-fork-fatfingers23-feat-likes-poetry.vercel.app/package/api/auth/atproto

if (user.value?.handle == null) {
authModal.open()
} else {
const result = likesData.value?.userHasLiked ? await unlikePackage() : await likePackage()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach could be improved a bit. Right now, the API request is sent immediately on every click.

It might be better to optimistically update the UI and debounce the request, so users can toggle the button freely and only the final state is sent to the API after a short delay. This would also allow us to provide immediate feedback that the action was registered, and handle request failures more gracefully (e.g., by rolling back the UI state or showing an error).

This would avoid unnecessary API calls and result in a smoother UX overall.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree, optimistic updates would fit nicely here and shouldn't be too difficult 👍

Comment on lines 26 to 29
if (hasLiked) {
throw createError({
status: 400,
message: 'User has already liked the package',
})
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: silently succeed here instead of throwing an error.

Scenario: user likes the package twice with quirky internet connection and/or slower than usual response times.

Potential result on the UI: user see the like added then seeing the error message that liking has failed.

Been there, done that, with an app with more than 500,000 users :D

Comment on lines 14 to 24
const body = await readBody<{ packageName: string }>(event)

if (!body.packageName) {
throw createError({
status: 400,
message: 'packageName is required',
})
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could leverage valibot here, this way you wouldn't have to resort to custom null checks to ensure that body is actually of type you expect it to be.

* Gets the definite answer if the user has liked a npm package. Either from the cache or the network
* @param packageName
* @param usersDid
* @returns
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

Copy link
Contributor

@alexdln alexdln Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about making xrpc routes (sth like /xrpc/npmx.feed.like.create)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we're just using the the server side oauth client so if we made this endpoint an XRPC it wouldn't be quite correct since it uses the cookie for authentication. But there is a plan for some XRPC endpoints for other things just need to make a middleware for it to authenticate the service auth jwt

nuxt.config.ts Outdated
driver: 'fsLite',
base: './.cache/atproto-oauth/session',
},
'generic-cache': {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably also need to update modules/cache.ts to configure production redis

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May have to leave that one to you 😅. Do you have something to read up on that? The local generic-cache defined there is used just locally for dev and then uses upstash redis like you did for the oauth session lock when in production if that makes a difference.

@@ -0,0 +1,246 @@
import { getCacheAdatper } from '../../cache'
import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
import type { Backlink } from '~~/shared/utils/constellation'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here and some other places too:

Suggested change
import type { Backlink } from '~~/shared/utils/constellation'
import type { Backlink } from '#shared/utils/constellation'

@43081j
Copy link
Collaborator

43081j commented Feb 2, 2026

looking good!

i left a bunch of comments, mostly about code organisation though tbh

Comment on lines 44 to 46
const data = ref<PackageLikes | null>(null)
const error = ref<Error | null>(null)
const pending = ref(false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const data = ref<PackageLikes | null>(null)
const error = ref<Error | null>(null)
const pending = ref(false)
const data = shallowRef<PackageLikes | null>(null)
const error = shallowRef<Error | null>(null)
const pending = shallowRef(false)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, I dont think we need these as composables. We almost never want the data from the ref itself, but rather the data returned by mutate. Just use $fetch where we need it or capsule just the $fetch in a util (IMO)

server: false,
})

const { mutate: likePackage } = useLikePackage(packageName.value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passing packageName.value means, that when packageName updates the likePackage will like the initial value.

@fatfingers23
Copy link
Contributor Author

Thank you everyone for all the feedback! I think I have addressed everything brought up. About to log off for a few hours and going come back to do some final testing and I have 2 todos I want to hit

  • There's a like type I can share with the backend
  • Look into the user session being loaded when not needed

@danielroe
Copy link
Member

it seems that likes aren't persisted, right? as in, when I navigate back to a package, it shows up as 'unliked'

@codecov
Copy link

codecov bot commented Feb 3, 2026

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

📝 Walkthrough

Walkthrough

Adds end-to-end package like support. Frontend: refactors useAtproto into a shared composable, adds authRedirect helper, integrates like button and optimistic UI on package pages, and updates several header components to use the composable. Backend: new /api/social endpoints (POST, DELETE, GET) for likes, a PackageLikesUtils class for Constellation-backed like queries, and OAuth scope checks. Infrastructure: cache abstraction with Local and Redis adapters and runtime selection, consolidated Nitro storage keys for atproto, new shared types/schemas/constants (ERROR_NEED_REAUTH, PACKAGE_SUBJECT_REF, LIKES_SCOPE), and Nuxt/Vite lex package entries.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description accurately reflects the implemented likes feature and related changes.
Linked Issues check ✅ Passed The changes implement package like/unlike UI and functionality as specified in issue #79.
Out of Scope Changes check ✅ Passed All modifications relate to the specified like/unlike features, caching, and auth flows with no unrelated changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (9)
app/utils/atproto/helpers.ts (1)

21-29: Consider adding type safety for error data access.

The access to fetchError?.data?.message relies on the error response structure from the server. The FetchError type from ofetch types data as any, so this works but lacks type safety. Consider adding a type guard or explicitly typing the expected error response structure.

♻️ Optional: Add type guard for error response
+interface ApiErrorResponse {
+  message?: string
+}
+
+function isApiError(data: unknown): data is ApiErrorResponse {
+  return typeof data === 'object' && data !== null && 'message' in data
+}
+
 export async function handleAuthError(
   fetchError: FetchError,
   userHandle?: string | null,
 ): Promise<never> {
-  const errorMessage = fetchError?.data?.message
+  const errorMessage = isApiError(fetchError?.data) ? fetchError.data.message : undefined
   if (errorMessage === ERROR_NEED_REAUTH && userHandle) {
     await authRedirect(userHandle)
   }
   throw fetchError
 }
app/composables/useAtproto.ts (2)

5-26: Duplicate definitions: authRedirect and handleAuthError are also defined in app/utils/atproto/helpers.ts.

These functions appear to be duplicated. The AuthModal.client.vue imports from helpers.ts while this file defines its own versions. Consider consolidating to a single source of truth to avoid divergence.

Additionally, the handleAuthError implementation here has an unsafe cast on line 20 (e as FetchError) without an instanceof check, unlike the version in helpers.ts which properly types its parameter.

♻️ Suggested approach

Re-export from the helpers file instead of duplicating:

-import { ERROR_NEED_REAUTH } from '#imports'
-import type { FetchError } from 'ofetch'
+import { authRedirect, handleAuthError } from '~/utils/atproto/helpers'
 import type { UserSession } from '#shared/schemas/userSession'
 
-export async function authRedirect(identifier: string, create: boolean = false) {
-  let query = { handle: identifier } as {}
-  if (create) {
-    query = { ...query, create: 'true' }
-  }
-  await navigateTo(
-    {
-      path: '/api/auth/atproto',
-      query,
-    },
-    { external: true },
-  )
-}
-
-export async function handleAuthError(e: unknown, userHandle?: string | null): Promise<never> {
-  const fetchError = e as FetchError
-  const errorMessage = fetchError?.data?.message
-  if (errorMessage === ERROR_NEED_REAUTH && userHandle) {
-    await authRedirect(userHandle)
-  }
-  throw e
-}
+export { authRedirect, handleAuthError }

49-76: Code duplication: useLikePackage and useUnlikePackage are nearly identical.

These two functions share the same structure, differing only in the HTTP method. Consider extracting a shared helper or parameterising the method.

♻️ Example refactor
function useMutatePackageLike(packageName: string, method: 'POST' | 'DELETE') {
  const { user } = useAtproto()
  const data = ref<PackageLikes | null>(null)
  const error = ref<Error | null>(null)
  const pending = ref(false)

  const mutate = async () => {
    pending.value = true
    error.value = null

    try {
      const result = await $fetch<PackageLikes>('/api/auth/social/like', {
        method,
        body: { packageName },
      })
      data.value = result
      return result
    } catch (e) {
      error.value = e as Error
      await handleAuthError(e, user.value?.handle)
    } finally {
      pending.value = false
    }
  }

  return { data, error, pending, mutate }
}

export function useLikePackage(packageName: string) {
  return useMutatePackageLike(packageName, 'POST')
}

export function useUnlikePackage(packageName: string) {
  return useMutatePackageLike(packageName, 'DELETE')
}

Also applies to: 78-105

app/pages/package/[...package].vue (2)

362-364: Acknowledged: TODO comment regarding session loading.

The comment at line 363 notes a potential unnecessary session load. This aligns with the PR discussion about investigating unnecessary user session loading.

Would you like me to help investigate whether the session can be lazily loaded or shared from a higher-level provider to avoid redundant fetches?


375-408: Consider providing user feedback when the like action fails.

When the like/unlike operation fails (lines 401-406), the UI silently reverts to the previous state without informing the user. This could be confusing if the action repeatedly fails (e.g., due to network issues or auth problems).

💡 Suggested improvement
   if (result.success) {
     // Update with server response
     likesData.value = result.data
   } else {
     // Revert on error
     likesData.value = {
       totalLikes: currentLikes,
       userHasLiked: currentlyLiked,
     }
+    // Consider showing a toast or notification
+    // e.g., useToast().error($t('package.like_error'))
   }
app/utils/atproto/likes.ts (1)

20-25: Control flow clarity: handleAuthError always throws, making subsequent return unreachable for FetchError cases.

When e instanceof FetchError is true, handleAuthError is called which always throws (see helpers.ts:28). The return { success: false, error: e as Error } on line 24 is only reached for non-FetchError exceptions. While functionally correct, this could be made clearer.

💡 Clearer control flow
   } catch (e) {
     if (e instanceof FetchError) {
-      await handleAuthError(e, userHandle)
+      await handleAuthError(e, userHandle) // throws
     }
     return { success: false, error: e as Error }
   }

Or restructure to make the intent explicit:

  } catch (e) {
    if (e instanceof FetchError) {
      // This will throw after potentially redirecting for re-auth
      await handleAuthError(e, userHandle)
    }
    // Only reached for non-fetch errors
    return { success: false, error: e as Error }
  }

Also applies to: 41-46

server/utils/cache/adapter.ts (1)

7-11: Redis client is instantiated on every call.

Each call to getCacheAdatper creates a new Redis client instance. If this function is called frequently, this could lead to connection overhead. Consider caching the Redis client instance or using a singleton pattern.

♻️ Proposed refactor using a cached client
+let cachedRedisClient: Redis | undefined
+
 export function getCacheAdatper(prefix: string): CacheAdapter {
   const config = useRuntimeConfig()

   if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) {
-    const redis = new Redis({
-      url: config.upstash.redisRestUrl,
-      token: config.upstash.redisRestToken,
-    })
+    if (!cachedRedisClient) {
+      cachedRedisClient = new Redis({
+        url: config.upstash.redisRestUrl,
+        token: config.upstash.redisRestToken,
+      })
+    }
-    return new RedisCacheAdatper(redis, prefix)
+    return new RedisCacheAdatper(cachedRedisClient, prefix)
   }
   return new LocalCacheAdapter()
 }
server/api/social/like.post.ts (1)

5-7: Inconsistent import alias styles.

The imports use different alias conventions: #shared/utils/constants (line 5) vs ~~/server/utils/atproto/oauth (line 7). Consider using a consistent alias style throughout the file for maintainability.

♻️ Proposed fix
 import { LIKES_SCOPE } from '#shared/utils/constants'
 import { PackageLikeBodySchema } from '#shared/schemas/social'
-import { throwOnMissingOAuthScope } from '~~/server/utils/atproto/oauth'
+import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
server/utils/cache/local.ts (1)

15-20: Consider consolidating staleness check logic.

The isCacheEntryStale function has similar logic to the one in shared/utils/fetch-cache-config.ts. While the types differ (LocalCachedEntry vs CachedFetchEntry), you could consider extracting a shared utility that accepts the required fields to reduce duplication.

Comment on lines +37 to +43
const result = await client.create(dev.npmx.feed.like, like)
if (!result) {
throw createError({
status: 500,
message: 'Failed to create a like',
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

@atproto/lex Client create method error handling behavior

💡 Result:

In the lex-cli–generated TypeScript clients (the style used throughout @atproto/api), the “create”/procedure call wrappers generally do not swallow errors: failures reject/throw and you’re expected to handle them.

What you typically get back on failure:

  • Transport / protocol errors propagate as-is, most commonly as XRPCError (from @atproto/xrpc). The generated code exposes a helper toKnownErr(e) for each endpoint. In many endpoints it’s a no-op (return e).[1]
  • For endpoints whose Lexicon declares named errors, the generated types define specific subclasses of XRPCError and toKnownErr(e) is intended to map an XRPCError into those subclasses (so callers can instanceof / switch on error types). Example: com.atproto.server.createSession declares AccountTakedownError and AuthFactorTokenRequiredError.[2]
  • At the protocol level, servers are encouraged to return a structured JSON error whose error “name” maps to the Lexicon’s declared errors, enabling this kind of typed error-handling in client libraries.[3]

If you share the exact package/version and the specific Client.create(...) you mean (there are multiple “client” layers in the atproto TS ecosystem), I can point to the exact implementation and the precise mapping behavior.

Sources: [1] getSession.ts (generated toKnownErr is identity) https://app.unpkg.com/@atproto/api@0.18.9/files/src/client/types/com/atproto/server/getSession.ts, [2] createSession.d.ts (declared typed errors) https://app.unpkg.com/@atproto/api@0.17.3/files/dist/client/types/com/atproto/server/createSession.d.ts, [3] AT Protocol XRPC spec (error name mapping) https://atproto.com/specs/xrpc


🏁 Script executed:

# First, let's find and examine the actual file
cat -n server/api/social/like.post.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1940


🏁 Script executed:

# Search for other usages of client.create to understand the pattern
rg 'client\.create' -A 3 -B 1 --type ts --type tsx

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Look for the client definition and type information
rg 'const client' server/api/social/ -A 2 -B 1 --type ts

Repository: npmx-dev/npmx.dev

Length of output: 587


🏁 Script executed:

# Search for ATProto imports and dependencies
rg '@atproto' --type ts --type json | head -20

Repository: npmx-dev/npmx.dev

Length of output: 1456


🏁 Script executed:

# Check if there's any try-catch wrapping this call at a higher level in the event handler
sed -n '1,46p' server/api/social/like.post.ts | tail -20

Repository: npmx-dev/npmx.dev

Length of output: 578


🏁 Script executed:

# Look at the full file to see if there's error handling at the function level
wc -l server/api/social/like.post.ts
cat -n server/api/social/like.post.ts

Repository: npmx-dev/npmx.dev

Length of output: 1974


Replace the error handling pattern with try-catch to properly handle thrown errors.

The if (!result) check assumes client.create() returns a falsy value on failure. However, @atproto/lex client methods throw XRPCError (or typed subtypes) on failure rather than returning null/undefined. This means the error check will never execute, and unhandled exceptions will propagate uncaught.

Wrap the client.create() call in a try-catch block and handle the thrown error appropriately:

Error handling pattern
try {
  const result = await client.create(dev.npmx.feed.like, like)
  return await likesUtil.likeAPackageAndReturnLikes(body.packageName, loggedInUsersDid, result.uri)
} catch (error) {
  throw createError({
    status: 500,
    message: 'Failed to create a like',
  })
}

Comment on lines +70 to +78
export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) {
const tokenInfo = await oAuthSession.getTokenInfo()
if (!tokenInfo.scope.includes(requiredScopes)) {
throw createError({
status: 403,
message: ERROR_NEED_REAUTH,
})
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Scope check using includes() may produce false positives.

Using string.includes() to check for scope presence could match partial scope names. For example, checking for repo:foo would incorrectly match a scope like repo:foobar. OAuth scopes are space-delimited, so a more robust check would split and perform an exact match.

🛡️ Proposed fix for exact scope matching
 export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) {
   const tokenInfo = await oAuthSession.getTokenInfo()
-  if (!tokenInfo.scope.includes(requiredScopes)) {
+  const grantedScopes = tokenInfo.scope.split(' ')
+  const requiredScopeList = requiredScopes.split(' ')
+  const hasAllScopes = requiredScopeList.every(scope => grantedScopes.includes(scope))
+  if (!hasAllScopes) {
     throw createError({
       status: 403,
       message: ERROR_NEED_REAUTH,
     })
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) {
const tokenInfo = await oAuthSession.getTokenInfo()
if (!tokenInfo.scope.includes(requiredScopes)) {
throw createError({
status: 403,
message: ERROR_NEED_REAUTH,
})
}
}
export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) {
const tokenInfo = await oAuthSession.getTokenInfo()
const grantedScopes = tokenInfo.scope.split(' ')
const requiredScopeList = requiredScopes.split(' ')
const hasAllScopes = requiredScopeList.every(scope => grantedScopes.includes(scope))
if (!hasAllScopes) {
throw createError({
status: 403,
message: ERROR_NEED_REAUTH,
})
}
}

Comment on lines +81 to +109
async getLikes(packageName: string, usersDid?: string | undefined): Promise<PackageLikes> {
//TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys
const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
const subjectRef = PACKAGE_SUBJECT_REF(packageName)

const cachedLikes = await this.cache.get<number>(totalLikesKey)
let totalLikes = 0
if (cachedLikes) {
totalLikes = cachedLikes
} else {
totalLikes = await this.constellationLikes(subjectRef)
await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
}

let userHasLiked = false
if (usersDid) {
const userCachedLike = await this.cache.get<boolean>(
CACHE_USER_LIKES_KEY(packageName, usersDid),
)
if (userCachedLike) {
userHasLiked = userCachedLike
} else {
userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
await this.cache.set(
CACHE_USER_LIKES_KEY(packageName, usersDid),
userHasLiked,
CACHE_MAX_AGE,
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat cached 0/false as cache hits.

Line 88 and Line 100 use truthy checks, which force a network fallback when the cached value is 0 or false. That defeats caching and can overwrite “not liked” states.

Proposed fix
-    if (cachedLikes) {
+    if (cachedLikes !== undefined) {
       totalLikes = cachedLikes
     } else {
       totalLikes = await this.constellationLikes(subjectRef)
       await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
     }
@@
-      if (userCachedLike) {
+      if (userCachedLike !== undefined) {
         userHasLiked = userCachedLike
       } else {
         userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
         await this.cache.set(
           CACHE_USER_LIKES_KEY(packageName, usersDid),
           userHasLiked,
           CACHE_MAX_AGE,
         )
       }

Comment on lines 11 to 13
return new RedisCacheAdatper(redis, prefix)
}
return new LocalCacheAdapter()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prefix parameter is ignored by LocalCacheAdapter.

The prefix parameter is passed to RedisCacheAdatper but not used by LocalCacheAdapter. This inconsistency could cause key collisions in development when multiple cache adapters are instantiated with different prefixes but share the same underlying storage.

🔧 Proposed fix

Either pass prefix to LocalCacheAdapter and implement key prefixing:

-  return new LocalCacheAdapter()
+  return new LocalCacheAdapter(prefix)

Or document that prefixing is only applied in production.

Comment on lines +12 to +13
* @param entry - The entry from the locla cache
* @returns
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor typos in documentation.

  • Line 12: "locla" should be "local"
  • Line 23: "implmentation" should be "implementation"
🔧 Proposed fix
 /**
- * Checks to see if a cache entry is stale locally
- * `@param` entry - The entry from the locla cache
+ * Checks to see if a cache entry is stale
+ * `@param` entry - The entry from the local cache
  * `@returns`
  */
 /**
- * Local implmentation of a cache to be used during development
+ * Local implementation of a cache to be used during development
  */

Also applies to: 23-23

Comment on lines +19 to +24
async get<T>(key: string): Promise<T | undefined> {
const formattedKey = this.formatKey(key)
const value = await this.redis.get<T>(formattedKey)
if (!value) return
return value
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Falsy value check causes valid cached values to be treated as cache misses.

The check if (!value) return will incorrectly return undefined for valid cached falsy values such as 0, '', false, or null. Upstash Redis returns null when a key doesn't exist, so the check should be explicit.

🐛 Proposed fix
 async get<T>(key: string): Promise<T | undefined> {
   const formattedKey = this.formatKey(key)
   const value = await this.redis.get<T>(formattedKey)
-  if (!value) return
+  if (value === null) return undefined
   return value
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async get<T>(key: string): Promise<T | undefined> {
const formattedKey = this.formatKey(key)
const value = await this.redis.get<T>(formattedKey)
if (!value) return
return value
}
async get<T>(key: string): Promise<T | undefined> {
const formattedKey = this.formatKey(key)
const value = await this.redis.get<T>(formattedKey)
if (value === null) return undefined
return value
}

Comment on lines +36 to +41
// ATProtocol
// Refrences used to link packages to things that are not inherently atproto
export const PACKAGE_SUBJECT_REF = (packageName: string) =>
`https://npmx.dev/package/${packageName}`
// OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes
export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor typo in comment.

Line 37: "Refrences" should be "References".

📝 Proposed fix
 // ATProtocol
-// Refrences used to link packages to things that are not inherently atproto
+// References used to link packages to things that are not inherently atproto
 export const PACKAGE_SUBJECT_REF = (packageName: string) =>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ATProtocol
// Refrences used to link packages to things that are not inherently atproto
export const PACKAGE_SUBJECT_REF = (packageName: string) =>
`https://npmx.dev/package/${packageName}`
// OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes
export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
// ATProtocol
// References used to link packages to things that are not inherently atproto
export const PACKAGE_SUBJECT_REF = (packageName: string) =>
`https://npmx.dev/package/${packageName}`
// OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes
export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
server/api/social/like.delete.ts (1)

21-24: Rename variable for clarity.

The variable getTheUsersLikedRecord reads like a function call rather than a data variable. Consider renaming to a noun-based name such as usersLikedRecord or existingLikeRecord for improved readability.

♻️ Proposed fix
-  const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord(
+  const existingLikeRecord = await likesUtil.getTheUsersLikedRecord(
     body.packageName,
     loggedInUsersDid,
   )

As per coding guidelines: Use clear, descriptive variable and function names.

Comment on lines +1 to +5
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check nuxt.config for auto-import configuration
echo "=== Checking nuxt.config for auto-import settings ==="
find . -name 'nuxt.config.*' -type f | head -5 | xargs -I {} sh -c 'echo "File: {}"; cat {}'

echo -e "\n=== Checking like.post.ts for LIKES_SCOPE import pattern ==="
fd -t f 'like.post.ts' | head -1 | xargs -I {} sh -c 'echo "File: {}"; cat {}'

echo -e "\n=== Checking like.delete.ts full content ==="
cat server/api/social/like.delete.ts

echo -e "\n=== Verifying LIKES_SCOPE definition in constants ==="
grep -n "LIKES_SCOPE" '#shared/utils/constants' 2>/dev/null || find . -path '*/shared/utils/constants*' -type f | xargs grep -l "LIKES_SCOPE" 2>/dev/null | head -1 | xargs grep -n "LIKES_SCOPE"

Repository: npmx-dev/npmx.dev

Length of output: 10575


Add missing import for LIKES_SCOPE.

LIKES_SCOPE is used on line 14 but is not imported. The comparable like.post.ts explicitly imports it from #shared/utils/constants, and the code will fail at runtime without it.

🔧 Proposed fix
 import * as v from 'valibot'
 import { Client } from '@atproto/lex'
 import * as dev from '#shared/types/lexicons/dev'
 import { PackageLikeBodySchema } from '#shared/schemas/social'
 import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
+import { LIKES_SCOPE } from '#shared/utils/constants'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
import { LIKES_SCOPE } from '#shared/utils/constants'

Comment on lines +26 to +34
if (getTheUsersLikedRecord) {
const client = new Client(oAuthSession)

await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
})
const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
return result
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for the AT Protocol delete operation.

The client.delete call can throw network or API errors. If it fails, the subsequent unlikeAPackageAndReturnLikes call would not execute, but there's no error handling to provide a meaningful response or log the failure. This aligns with the PR author's note about "need for more error handling".

Consider wrapping the operation in a try-catch to handle failures gracefully:

🛡️ Proposed fix
   if (getTheUsersLikedRecord) {
     const client = new Client(oAuthSession)
 
-    await client.delete(dev.npmx.feed.like, {
-      rkey: getTheUsersLikedRecord.rkey,
-    })
-    const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
-    return result
+    try {
+      await client.delete(dev.npmx.feed.like, {
+        rkey: getTheUsersLikedRecord.rkey,
+      })
+      const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
+      return result
+    }
+    catch (error) {
+      console.error(`Failed to delete like for package ${body.packageName}:`, error)
+      throw createError({ statusCode: 500, statusMessage: 'Failed to unlike package' })
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (getTheUsersLikedRecord) {
const client = new Client(oAuthSession)
await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
})
const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
return result
}
if (getTheUsersLikedRecord) {
const client = new Client(oAuthSession)
try {
await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
})
const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
return result
}
catch (error) {
console.error(`Failed to delete like for package ${body.packageName}:`, error)
throw createError({ statusCode: 500, statusMessage: 'Failed to unlike package' })
}
}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/pages/package/[...package].vue (1)

555-579: Expose toggle state to assistive tech and block repeat clicks while pending.

Add aria-pressed and disable the button during updates for clearer UX and accessibility.

♿ Proposed refinement
             <button
               `@click`="likeAction"
               type="button"
               class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
               :title="$t('package.links.like')"
+              :aria-pressed="!!likesData?.userHasLiked"
+              :disabled="isLikeActionPending"
             >

Comment on lines +378 to +407
const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
return
}

if (isLikeActionPending.value) return

const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0

// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}

isLikeActionPending.value = true

const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)

isLikeActionPending.value = false

if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ensure pending state resets even when the request throws.

If togglePackageLike rejects, isLikeActionPending stays true and the optimistic count never reverts. Wrap the call in try/catch/finally.

🛡️ Proposed fix
   // Optimistic update
   likesData.value = {
     totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
     userHasLiked: !currentlyLiked,
   }

   isLikeActionPending.value = true

-  const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
-
-  isLikeActionPending.value = false
-
-  if (result.success) {
-    // Update with server response
-    likesData.value = result.data
-  } else {
-    // Revert on error
-    likesData.value = {
-      totalLikes: currentLikes,
-      userHasLiked: currentlyLiked,
-    }
-  }
+  try {
+    const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
+    if (result.success) {
+      // Update with server response
+      likesData.value = result.data
+    } else {
+      // Revert on error
+      likesData.value = {
+        totalLikes: currentLikes,
+        userHasLiked: currentlyLiked,
+      }
+    }
+  } catch {
+    likesData.value = {
+      totalLikes: currentLikes,
+      userHasLiked: currentlyLiked,
+    }
+  } finally {
+    isLikeActionPending.value = false
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
return
}
if (isLikeActionPending.value) return
const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0
// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}
isLikeActionPending.value = true
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
isLikeActionPending.value = false
if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
return
}
if (isLikeActionPending.value) return
const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0
// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}
isLikeActionPending.value = true
try {
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
} catch {
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
} finally {
isLikeActionPending.value = false
}
}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/pages/package/[...package].vue (1)

552-569: Expose toggle state for assistive tech.

Consider adding aria-pressed so screen readers announce the like state.

♿ Suggested tweak
             <button
               `@click`="likeAction"
               type="button"
               class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
               :title="$t('package.links.like')"
+              :aria-pressed="likesData?.userHasLiked ?? false"
             >

Comment on lines +386 to +389
// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp optimistic counts to avoid negatives.

If currentLikes is stale (e.g., 0 while userHasLiked is true), the optimistic decrement can go negative.

🧮 Suggested clamp
-  likesData.value = {
-    totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
-    userHasLiked: !currentlyLiked,
-  }
+  const nextLikes = currentlyLiked ? Math.max(0, currentLikes - 1) : currentLikes + 1
+  likesData.value = {
+    totalLikes: nextLikes,
+    userHasLiked: !currentlyLiked,
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
// Optimistic update
const nextLikes = currentlyLiked ? Math.max(0, currentLikes - 1) : currentLikes + 1
likesData.value = {
totalLikes: nextLikes,
userHasLiked: !currentlyLiked,
}

@fatfingers23
Copy link
Contributor Author

It's finally done 😭. After testing by deploying to another vercel project I have finally found the answer. The api endpoints for likes was caching and still showing that initial load of zero likes. Added for the /api/social/** endpoints to never cache since will most likely be handling our own caching in these instances and are for likes.

@danielroe I think it's ready if you want to give it another test and look.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/pages/package/[...package].vue (1)

552-569: Expose pending/pressed state on the like button for UX + a11y.

Consider disabling the button while pending and reflecting the pressed state.

♿ Suggested tweak
 <button
   `@click`="likeAction"
   type="button"
   class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
   :title="$t('package.links.like')"
+  :disabled="isLikeActionPending"
+  :aria-busy="isLikeActionPending"
+  :aria-pressed="likesData?.userHasLiked ?? false"
 >

Comment on lines +33 to 35
nitroConfig.storage.atproto = {
driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache',
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "storage\.atproto|useStorage\(['\"]atproto:"

Repository: npmx-dev/npmx.dev

Length of output: 208


🏁 Script executed:

cat -n modules/cache.ts | sed -n '1,200p'

Repository: npmx-dev/npmx.dev

Length of output: 1362


Always define nitroConfig.storage.atproto in modules/cache.ts
Outside Vercel the atproto mount is only configured when provider === 'vercel', so useStorage('atproto:…') will fail in dev/test. Add a default mount (for example with the vercel-runtime-cache driver) for non-Vercel environments.

* Storage key prefix for oauth session storage.
*/
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'atproto:oauth-session'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential silent logout after storage namespace change.

Line 7 switches the storage prefix, so existing cookies will still reference keys stored under the legacy namespace; users will be forced to re-auth and the stale cookie will linger. If that is not intended, add a one-time legacy lookup/migration (or clear the cookie on miss).

🔧 Optional migration fallback
 export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'atproto:oauth-session'
+const LEGACY_OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'

 export class OAuthSessionStore implements NodeSavedSessionStore {
   // TODO: not sure if we will support multi accounts, but if we do in the future will need to change this around
   private readonly cookieKey = 'oauth:atproto:session'
   private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE)
+  private readonly legacyStorage = useStorage(LEGACY_OAUTH_SESSION_CACHE_STORAGE_BASE)

   constructor(private event: H3Event) {}

   async get(): Promise<NodeSavedSession | undefined> {
     const sessionKey = getCookie(this.event, this.cookieKey)
     if (!sessionKey) return
     const result = await this.storage.getItem<NodeSavedSession>(sessionKey)
-    if (!result) return
-    return result
+    if (result) return result
+    const legacy = await this.legacyStorage.getItem<NodeSavedSession>(sessionKey)
+    if (!legacy) {
+      deleteCookie(this.event, this.cookieKey)
+      return
+    }
+    await this.storage.setItem<NodeSavedSession>(sessionKey, legacy)
+    await this.legacyStorage.del(sessionKey)
+    return legacy
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'atproto:oauth-session'
export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'atproto:oauth-session'
const LEGACY_OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session'
export class OAuthSessionStore implements NodeSavedSessionStore {
// TODO: not sure if we will support multi accounts, but if we do in the future will need to change this around
private readonly cookieKey = 'oauth:atproto:session'
private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE)
private readonly legacyStorage = useStorage(LEGACY_OAUTH_SESSION_CACHE_STORAGE_BASE)
constructor(private event: H3Event) {}
async get(): Promise<NodeSavedSession | undefined> {
const sessionKey = getCookie(this.event, this.cookieKey)
if (!sessionKey) return
const result = await this.storage.getItem<NodeSavedSession>(sessionKey)
if (result) return result
const legacy = await this.legacyStorage.getItem<NodeSavedSession>(sessionKey)
if (!legacy) {
deleteCookie(this.event, this.cookieKey)
return
}
await this.storage.setItem<NodeSavedSession>(sessionKey, legacy)
await this.legacyStorage.del(sessionKey)
return legacy
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: package likes

7 participants