refactor: use "use cache: remote" for request-time reads and updateTag/refresh for mutations

- Replace `"use cache"` with `"use cache: remote"` on functions that run inside dynamic holes (`getContributions`, `getRepos`, `getPostStats`, `getCachedComments`) so entries are shared across server instances
- Switch comment and view server actions from `revalidatePath`/`revalidateTag` to `updateTag` + `refresh` for read-your-writes invalidation
- Extract a `commentsTag(pageSlug)` helper so each page's comment cache is expired independently instead of blowing the whole "comments" tag
- Refactor `getComments` to delegate to a cached inner function that throws on failure (so errors are never cached); `getCommentCount` now reuses that cache entry instead of issuing a separate COUNT query
- Drop `lib/data/views.ts` (`getAllViewCounts`) — the `/api/hits` route now calls `getPostStats()` instead
- Remove `revalidateTag("views")` from `incrementViews`; view counts in `getPostStats` are allowed to lag by a cache TTL
- Remove the `Suspense`/`Skeleton` wrapper on the projects page since `getContributions` is now a remote-cached call
This commit is contained in:
jake committed 2026-10-03 09:50:06 -04:00
1 parent 373f1cfc75
commit d3b2d07de7
10 files changed
+75 -96

No files matched your search

+7 -4
View File
@@ -94,7 +94,7 @@ export { Button };
### Server Actions & Error Handling
- Server actions live in `lib/server/*.ts` files starting with `"use server"`. Every export of such a file is a public endpoint callable by anyone, so authenticate (`auth.api.getSession({ headers: await headers() })`) and validate all arguments inside the action, and only export async functions (types are fine).
- Actions take typed arguments (not `FormData`) and throw `Error` on failure: do the auth check before the `try`, then do the DB work inside a `try/catch` that logs with a `[server/<file>]` prefix and rethrows with `{ cause }`. Call `revalidatePath()` / `revalidateTag()` after mutations.
- Actions take typed arguments (not `FormData`) and throw `Error` on failure: do the auth check before the `try`, then do the DB work inside a `try/catch` that logs with a `[server/<file>]` prefix and rethrows with `{ cause }`. After mutations, call `updateTag()` for each cache tag the write affects (read-your-writes) and `refresh()` to re-render the caller's page; reserve `revalidatePath()` for when prerendered output itself changed.
- Don't put read queries in a `"use server"` file (that makes them public endpoints); put them in `lib/data/` with `import "server-only"` and call them from server components.
```typescript
@@ -121,7 +121,8 @@ export const createComment = async (data: {
userId: session.user.id,
});
revalidatePath(`/${data.pageSlug}`);
updateTag(commentsTag(data.pageSlug));
refresh();
} catch (error) {
console.error("[server/comments] error creating comment:", error);
throw new Error("Failed to create comment", { cause: error });
@@ -135,7 +136,9 @@ export const createComment = async (data: {
- Schema in `lib/db/schema.ts`; migrations are generated into `drizzle/` with `pnpm db:generate` and applied with `pnpm db:migrate`. Drizzle ORM is a pinned `1.0.0-rc` prerelease, so check its docs for v1 APIs (e.g. `defineRelations`).
- `cacheComponents` is enabled. Cache expensive or remote reads with `"use cache"` and an explicit `cacheLife(...)` (add `cacheTag(...)` when something needs to invalidate it). Examples: `app/projects/github.ts`, `components/third-party/tweet.tsx`.
- After mutations, call `revalidatePath()` / `revalidateTag()`, and only revalidate tags that some `cacheTag()` actually sets.
- Use `"use cache: remote"` for reads that run at request time (inside a dynamic hole, e.g. after `headers()`/`connection()`) so the entry is shared across server instances; plain `"use cache"` is fine for anything that ends up in a prerendered shell. Example: `lib/data/comments.ts`.
- Don't catch-and-return-a-fallback inside a cached function, or the failure gets cached; let it throw and catch in an uncached wrapper.
- After mutations, call `updateTag()` (server actions) or `revalidateTag()` (route handlers), and only expire tags that some `cacheTag()` actually sets.
```typescript
export const getData = async (slug: string) => {
@@ -157,7 +160,7 @@ components/ # React components
third-party/ # Embeds (tweet, gist, youtube, codepen)
lib/ # Core utilities and configuration
db/ # Drizzle schema and database client
data/ # Server-only reads (import "server-only"): comments, views, cached post stats
data/ # Server-only reads (import "server-only"): comments, cached post stats
server/ # Server actions ("use server") -- mutations only: comments, views
config/ # Site and author configuration
auth.ts # Better Auth server config (GitHub OAuth)
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getAllViewCounts } from "@/lib/data/views";
import { getPostStats } from "@/lib/data/stats";
export const GET = async (): Promise<
NextResponse<{
@@ -14,7 +14,7 @@ export const GET = async (): Promise<
}>
> => {
// note: while hits have been renamed to views in most places, this API shouldn't change due to it being snapshotted
const views = await getAllViewCounts();
const { views } = await getPostStats();
const total = {
hits: Object.values(views).reduce((acc, curr) => acc + curr, 0),
+2 -2
View File
@@ -11,7 +11,7 @@ export const getContributions = async (): Promise<
level: number;
}>
> => {
"use cache";
"use cache: remote";
cacheLife("minutes");
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
@@ -81,7 +81,7 @@ export const getContributions = async (): Promise<
};
export const getRepos = async (): Promise<Repository[] | undefined> => {
"use cache";
"use cache: remote";
cacheLife("minutes");
try {
+9 -12
View File
@@ -7,7 +7,6 @@ import { PageTitle } from "@/components/layout/page-title";
import { FadeTransition } from "@/components/page-transition";
import { RelativeTime } from "@/components/relative-time";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { createMetadata } from "@/lib/metadata";
import { cn } from "@/lib/utils";
@@ -45,17 +44,15 @@ const Page = async () => {
</a>
</h2>
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
{contributions.length > 0 ? (
<div className={cn("mx-auto mt-4 mb-8")}>
<ActivityCalendar data={contributions} noun="contribution" />
</div>
) : (
<p className="my-4 text-center text-muted-foreground">
Unable to load contribution data at this time.
</p>
)}
</Suspense>
{contributions.length > 0 ? (
<div className={cn("mx-auto mt-4 mb-8")}>
<ActivityCalendar data={contributions} noun="contribution" />
</div>
) : (
<p className="my-4 text-center text-muted-foreground">
Unable to load contribution data at this time.
</p>
)}
<h2 className="my-3.5 text-lg font-medium">
<a
+39 -35
View File
@@ -1,5 +1,6 @@
import "server-only";
import { desc, eq, sql } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
@@ -11,51 +12,54 @@ export type CommentWithUser = typeof schema.comment.$inferSelect & {
user: CommentUser;
};
/** Cache tag for a single page's comments; expired by the actions in lib/server/comments.ts */
export const commentsTag = (pageSlug: string) => `comments-${pageSlug}`;
/**
* Shared across server instances since it's read at request time (inside dynamic holes), not at prerender. Throws on
* failure so an error is never cached.
*/
const getCachedComments = async (pageSlug: string): Promise<CommentWithUser[]> => {
"use cache: remote";
cacheLife("hours");
cacheTag(commentsTag(pageSlug));
// Fetch all comments for the page with user details
const commentsWithUsers = await db
.select()
.from(schema.comment)
.innerJoin(schema.user, eq(schema.comment.userId, schema.user.id))
.where(eq(schema.comment.pageSlug, pageSlug))
.orderBy(desc(schema.comment.createdAt));
return commentsWithUsers.map(({ comment, user }) =>
Object.assign(comment, {
user: {
// we're namely worried about keeping the user's email private here, but nothing sensitive is stored in the db
id: user.id,
name: user.name,
image: user.image,
},
}),
);
};
/**
* Retrieves all comments for a given slug, newest first, with each author's public profile fields
*/
export const getComments = async (pageSlug: string): Promise<CommentWithUser[]> => {
try {
// Fetch all comments for the page with user details
const commentsWithUsers = await db
.select()
.from(schema.comment)
.innerJoin(schema.user, eq(schema.comment.userId, schema.user.id))
.where(eq(schema.comment.pageSlug, pageSlug))
.orderBy(desc(schema.comment.createdAt));
return commentsWithUsers.map(({ comment, user }) =>
Object.assign(comment, {
user: {
// we're namely worried about keeping the user's email private here, but nothing sensitive is stored in the db
id: user.id,
name: user.name,
image: user.image,
},
}),
);
return await getCachedComments(pageSlug);
} catch (error) {
console.error("[data/comments] error fetching comments:", error);
// Return empty array instead of throwing during prerendering
return [];
}
};
/**
* Retrieves the number of comments for a given slug (uncached -- call from inside a <Suspense> boundary)
* Retrieves the number of comments for a given slug (shares getComments' cache entry)
*/
export const getCommentCount = async (slug: string): Promise<number> => {
try {
const result = await db
.select({
count: sql<number>`cast(count(${schema.comment.id}) as int)`,
})
.from(schema.comment)
.where(eq(schema.comment.pageSlug, slug));
return result[0]?.count ?? 0;
} catch (error) {
console.error("[data/comments] error fetching comment count:", error);
return 0;
}
export const getCommentCount = async (pageSlug: string): Promise<number> => {
const comments = await getComments(pageSlug);
return comments.length;
};
+3 -3
View File
@@ -12,12 +12,12 @@ export type PostStatsData = {
/**
* View and comment counts for ALL pages, keyed by slug (e.g. "notes/dark-mode").
* Cached briefly; invalidated by `revalidateTag("views")` / `revalidateTag("comments")`.
* Cached briefly (views can lag by a minute or so); comment changes expire it via `updateTag("comments")`.
*/
export const getPostStats = async (): Promise<PostStatsData> => {
"use cache";
"use cache: remote";
cacheLife("minutes");
cacheTag("views", "comments");
cacheTag("comments");
try {
const [pageRows, commentRows] = await Promise.all([
-22
View File
@@ -1,22 +0,0 @@
import "server-only";
import { db } from "@/lib/db";
import { page } from "@/lib/db/schema";
/**
* Retrieves the numbers of views for ALL slugs (uncached)
*/
export const getAllViewCounts = async (): Promise<Record<string, number>> => {
try {
const pages = await db.select().from(page);
return pages.reduce(
(acc, p) => {
acc[p.slug] = p.views;
return acc;
},
{} as Record<string, number>,
);
} catch (error) {
console.error("[data/views] fatal error:", error);
return {};
}
};
+13 -9
View File
@@ -1,11 +1,12 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import { refresh, updateTag } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { commentsTag } from "@/lib/data/comments";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
import { getPostByPageSlug } from "@/lib/posts";
@@ -66,9 +67,10 @@ export const createComment = async (data: {
userId: session.user.id,
});
// Revalidate page and the cached comment counts
revalidatePath(`/${pageSlug}`);
revalidateTag("comments", "max");
// Expire this page's cached comments and the post stats, then refresh the commenter's view
updateTag(commentsTag(pageSlug));
updateTag("comments");
refresh();
} catch (error) {
console.error("[server/comments] error creating comment:", error);
throw new Error("Failed to create comment", { cause: error });
@@ -119,8 +121,9 @@ export const updateComment = async (commentId: string, content: string) => {
})
.where(eq(schema.comment.id, commentId));
// Revalidate page
revalidatePath(`/${comment.pageSlug}`);
// Expire this page's cached comments, then refresh the commenter's view
updateTag(commentsTag(comment.pageSlug));
refresh();
} catch (error) {
console.error("[server/comments] error updating comment:", error);
throw new Error("Failed to update comment", { cause: error });
@@ -163,9 +166,10 @@ export const deleteComment = async (commentId: string) => {
// Delete the comment
await db.delete(schema.comment).where(eq(schema.comment.id, commentId));
// Revalidate page and the cached comment counts
revalidatePath(`/${comment.pageSlug}`);
revalidateTag("comments", "max");
// Expire this page's cached comments and the post stats, then refresh the commenter's view
updateTag(commentsTag(comment.pageSlug));
updateTag("comments");
refresh();
} catch (error) {
console.error("[server/comments] error deleting comment:", error);
throw new Error("Failed to delete comment", { cause: error });
-4
View File
@@ -1,7 +1,6 @@
"use server";
import { sql } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";
import { page } from "@/lib/db/schema";
@@ -27,9 +26,6 @@ export const incrementViews = async (slug: string): Promise<number> => {
})
.returning({ views: page.views });
// Mark the cached post stats (lib/data/stats.ts) stale so lists pick up the new count
revalidateTag("views", "max");
return result.views;
} catch (error) {
console.error("[server/views] error incrementing views:", error);
-3
View File
@@ -36,9 +36,6 @@ const nextConfig = {
...(process.env.NEXT_PUBLIC_ONION_DOMAIN ? [process.env.NEXT_PUBLIC_ONION_DOMAIN] : []),
],
},
staleTimes: {
dynamic: 0, // disable client-side router cache for dynamic pages
},
},
headers: async () => [
// https://community.torproject.org/onion-services/advanced/onion-location/