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:
Thomas Camlong committed 2026-05-18 14:51:44 +02:00
1 parent 2c388bd449
commit 22af9e2c3e
11 files changed
+146 -5

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
"./server": "./src/server.ts",
"./websocket": "./src/websocket.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",
"types": "./index.ts",
+1
View File
@@ -0,0 +1 @@
export { getSharedBoardWithWhereAsync } from "./board";
+87
View File
@@ -1527,6 +1527,93 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
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 groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),