feat(boards): cache named board data with tag-based invalidation
Named boards are now served from Next.js unstable_cache (1h TTL,
tag `board-{name}`). A new `getSharedBoardWithWhereAsync` helper
fetches the board without per-user data so the result is safe to
share across requests.
Cache is busted via `invalidateBoardCacheAsync` (revalidateTag +
revalidatePath) from board save, layout save, and partial settings
mutations.
This commit is contained in:
11 files changed
+146
-5
No files matched your search
@@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string }>({
|
|||||||
async getInitialBoardAsync() {
|
async getInitialBoardAsync() {
|
||||||
return await api.board.getHomeBoard();
|
return await api.board.getHomeBoard();
|
||||||
},
|
},
|
||||||
|
getBoardName() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string; name: string }>({
|
|||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
|
getBoardName({ name }) {
|
||||||
|
return name;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -18,6 +18,7 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
import { getCachedBoardByName } from "../_cached-board";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
import type { Board, Item } from "../_types";
|
import type { Board, Item } from "../_types";
|
||||||
import { DynamicClientBoard } from "./_dynamic-client";
|
import { DynamicClientBoard } from "./_dynamic-client";
|
||||||
@@ -29,10 +30,12 @@ export type Params = Record<string, unknown>;
|
|||||||
|
|
||||||
interface Props<TParams extends Params> {
|
interface Props<TParams extends Params> {
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
|
getBoardName: (params: TParams) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
getBoardName,
|
||||||
}: Props<TParams>) => {
|
}: Props<TParams>) => {
|
||||||
return {
|
return {
|
||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
@@ -42,9 +45,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
|
const boardName = getBoardName(resolvedParams);
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
const [board, session] = await Promise.all([
|
||||||
|
boardName ? getCachedBoardByName(boardName) : getInitialBoard(resolvedParams),
|
||||||
|
auth(),
|
||||||
|
]);
|
||||||
|
|
||||||
const itemsMap = board.items.reduce((acc, item) => {
|
const itemsMap = board.items.reduce((acc, item) => {
|
||||||
const existing = acc.get(item.kind);
|
const existing = acc.get(item.kind);
|
||||||
@@ -81,7 +88,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getInitialBoard(await params);
|
const resolvedParams = await params;
|
||||||
|
const boardName = getBoardName(resolvedParams);
|
||||||
|
|
||||||
|
const board = boardName
|
||||||
|
? await getCachedBoardByName(boardName)
|
||||||
|
: await getInitialBoard(resolvedParams);
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
@@ -30,6 +29,8 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { invalidateBoardCacheAsync } from "../_invalidate-board-cache";
|
||||||
|
|
||||||
import { useItemActions } from "~/components/board/items/item-actions";
|
import { useItemActions } from "~/components/board/items/item-actions";
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
@@ -153,7 +154,7 @@ const EditModeMenu = () => {
|
|||||||
message: t("notification.success.message"),
|
message: t("notification.success.message"),
|
||||||
});
|
});
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
void invalidateBoardCacheAsync(board.name);
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
|
import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
board: Board;
|
||||||
@@ -20,6 +21,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
|||||||
onSettled() {
|
onSettled() {
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void utils.board.getHomeBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
|
void invalidateBoardCacheAsync(board.name);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
|
import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache";
|
||||||
|
|
||||||
export const useSavePartialSettingsMutation = (board: Board) => {
|
export const useSavePartialSettingsMutation = (board: Board) => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -8,6 +9,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
|
|||||||
onSettled() {
|
onSettled() {
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void utils.board.getHomeBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
|
void invalidateBoardCacheAsync(board.name);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
|
import { db, eq, sql } from "@homarr/db";
|
||||||
|
import { boards } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board";
|
||||||
|
|
||||||
|
export function getCachedBoardByName(name: string) {
|
||||||
|
const tag = `board-${name.toLowerCase()}`;
|
||||||
|
return unstable_cache(
|
||||||
|
async () => {
|
||||||
|
const where = eq(sql`UPPER(${boards.name})`, name.toUpperCase());
|
||||||
|
return await getSharedBoardWithWhereAsync(db, where);
|
||||||
|
},
|
||||||
|
["board-by-name", name.toLowerCase()],
|
||||||
|
{
|
||||||
|
tags: [tag],
|
||||||
|
revalidate: 3600,
|
||||||
|
},
|
||||||
|
)();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
export async function invalidateBoardCacheAsync(boardName: string) {
|
||||||
|
revalidateTag(`board-${boardName.toLowerCase()}`);
|
||||||
|
revalidatePath(`/boards/${boardName}`, "page");
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./websocket": "./src/websocket.ts",
|
"./websocket": "./src/websocket.ts",
|
||||||
"./shared": "./src/shared.ts",
|
"./shared": "./src/shared.ts",
|
||||||
"./open-api": "./src/open-api.ts"
|
"./open-api": "./src/open-api.ts",
|
||||||
|
"./router/board": "./src/router/board-exports.ts"
|
||||||
},
|
},
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { getSharedBoardWithWhereAsync } from "./board";
|
||||||
@@ -1527,6 +1527,93 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
|
|||||||
return [...itemElements, ...sectionElements];
|
return [...itemElements, ...sectionElements];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the board structure without per-user data (collapse states, permissions).
|
||||||
|
* This result is safe to cache because it's identical for all viewers.
|
||||||
|
*/
|
||||||
|
export const getSharedBoardWithWhereAsync = async (db: Database, where: SQL<unknown>) => {
|
||||||
|
const board = await db.query.boards.findFirst({
|
||||||
|
where,
|
||||||
|
with: {
|
||||||
|
creator: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
with: {
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
with: {
|
||||||
|
integrations: {
|
||||||
|
with: {
|
||||||
|
integration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Board not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sections, items, layouts, ...otherBoardProperties } = board;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...otherBoardProperties,
|
||||||
|
userPermissions: [] as { permission: string }[],
|
||||||
|
groupPermissions: [] as unknown[],
|
||||||
|
layouts: layouts
|
||||||
|
.map(({ boardId: _, ...layout }) => layout)
|
||||||
|
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||||
|
sections: sections.map((section) =>
|
||||||
|
parseSection({
|
||||||
|
...section,
|
||||||
|
xOffset: section.xOffset,
|
||||||
|
yOffset: section.yOffset,
|
||||||
|
options: superjson.parse(section.options ?? emptySuperJSON),
|
||||||
|
layouts: section.layouts.map((layout) => ({
|
||||||
|
xOffset: layout.xOffset,
|
||||||
|
yOffset: layout.yOffset,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
parentSectionId: layout.parentSectionId,
|
||||||
|
layoutId: layout.layoutId,
|
||||||
|
})),
|
||||||
|
collapsed: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
|
parseItem({
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) => ({
|
||||||
|
xOffset: layout.xOffset,
|
||||||
|
yOffset: layout.yOffset,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
layoutId: layout.layoutId,
|
||||||
|
sectionId: layout.sectionId,
|
||||||
|
})),
|
||||||
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||||
where: eq(groupMembers.userId, userId ?? ""),
|
where: eq(groupMembers.userId, userId ?? ""),
|
||||||
|
|||||||
Reference in new issue
Block a user