-
-
Notifications
You must be signed in to change notification settings - Fork 180
feat: package likes #712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: package likes #712
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Adebesin-Cell
left a comment
There was a problem hiding this 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
app/pages/package/[...package].vue
Outdated
| if (user.value?.handle == null) { | ||
| authModal.open() | ||
| } else { | ||
| const result = likesData.value?.userHasLiked ? await unlikePackage() : await likePackage() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 👍
server/api/social/like.post.ts
Outdated
| if (hasLiked) { | ||
| throw createError({ | ||
| status: 400, | ||
| message: 'User has already liked the package', | ||
| }) | ||
| } |
There was a problem hiding this comment.
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
server/api/auth/social/like.post.ts
Outdated
| const body = await readBody<{ packageName: string }>(event) | ||
|
|
||
| if (!body.packageName) { | ||
| throw createError({ | ||
| status: 400, | ||
| message: 'packageName is required', | ||
| }) | ||
| } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👀
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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': { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
server/utils/atproto/utils/likes.ts
Outdated
| @@ -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' | |||
There was a problem hiding this comment.
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:
| import type { Backlink } from '~~/shared/utils/constellation' | |
| import type { Backlink } from '#shared/utils/constellation' |
3807cb3 to
3b8d928
Compare
|
looking good! i left a bunch of comments, mostly about code organisation though tbh |
app/composables/useAtproto.ts
Outdated
| const data = ref<PackageLikes | null>(null) | ||
| const error = ref<Error | null>(null) | ||
| const pending = ref(false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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) |
There was a problem hiding this comment.
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)
app/pages/package/[...package].vue
Outdated
| server: false, | ||
| }) | ||
|
|
||
| const { mutate: likePackage } = useLikePackage(packageName.value) |
There was a problem hiding this comment.
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.
911bcd2 to
a8f9a02
Compare
|
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
|
|
it seems that likes aren't persisted, right? as in, when I navigate back to a package, it shows up as 'unliked' |
934fd6d to
f057772
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
📝 WalkthroughWalkthroughAdds 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)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this 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?.messagerelies on the error response structure from the server. TheFetchErrortype fromofetchtypesdataasany, 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:authRedirectandhandleAuthErrorare also defined inapp/utils/atproto/helpers.ts.These functions appear to be duplicated. The
AuthModal.client.vueimports fromhelpers.tswhile this file defines its own versions. Consider consolidating to a single source of truth to avoid divergence.Additionally, the
handleAuthErrorimplementation here has an unsafe cast on line 20 (e as FetchError) without aninstanceofcheck, unlike the version inhelpers.tswhich 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:useLikePackageanduseUnlikePackageare 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:handleAuthErroralways throws, making subsequent return unreachable forFetchErrorcases.When
e instanceof FetchErroris true,handleAuthErroris called which always throws (seehelpers.ts:28). Thereturn { success: false, error: e as Error }on line 24 is only reached for non-FetchErrorexceptions. 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
getCacheAdatpercreates 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
isCacheEntryStalefunction has similar logic to the one inshared/utils/fetch-cache-config.ts. While the types differ (LocalCachedEntryvsCachedFetchEntry), you could consider extracting a shared utility that accepts the required fields to reduce duplication.
| const result = await client.create(dev.npmx.feed.like, like) | ||
| if (!result) { | ||
| throw createError({ | ||
| status: 500, | ||
| message: 'Failed to create a like', | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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 helpertoKnownErr(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
XRPCErrorandtoKnownErr(e)is intended to map anXRPCErrorinto those subclasses (so callers caninstanceof/ switch on error types). Example:com.atproto.server.createSessiondeclaresAccountTakedownErrorandAuthFactorTokenRequiredError.[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 -50Repository: 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 tsxRepository: 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 tsRepository: npmx-dev/npmx.dev
Length of output: 587
🏁 Script executed:
# Search for ATProto imports and dependencies
rg '@atproto' --type ts --type json | head -20Repository: 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 -20Repository: 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.tsRepository: 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',
})
}
| 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, | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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, | |
| }) | |
| } | |
| } |
| 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, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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,
)
}
server/utils/cache/adapter.ts
Outdated
| return new RedisCacheAdatper(redis, prefix) | ||
| } | ||
| return new LocalCacheAdapter() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| * @param entry - The entry from the locla cache | ||
| * @returns |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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 | |
| } |
| // 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}` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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}` |
There was a problem hiding this 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
getTheUsersLikedRecordreads like a function call rather than a data variable. Consider renaming to a noun-based name such asusersLikedRecordorexistingLikeRecordfor 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.
| 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' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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.
| 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' |
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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' }) | |
| } | |
| } |
There was a problem hiding this 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-pressedand 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" >
| 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, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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 | |
| } | |
| } |
e43c70d to
49ef2d6
Compare
There was a problem hiding this 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-pressedso 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" >
| // Optimistic update | ||
| likesData.value = { | ||
| totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1, | ||
| userHasLiked: !currentlyLiked, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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, | |
| } |
|
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 @danielroe I think it's ready if you want to give it another test and look. |
There was a problem hiding this 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" >
| nitroConfig.storage.atproto = { | ||
| driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache', | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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 | |
| } | |
| } |
Our first social feature 🥳 This closes #79
Adds
useStoragelocally and redis in prod with set expireCould be Improved
Concerns
I probably won't be able to address feedback till tomorrow around 22:00 UTC
Zen.mp4