feat(api): add Redis-backed query cache persistence layer

Add tRPC router (queryCache.getItem/setItem/removeItem) for persisting
TanStack Query cache entries to Redis, scoped per user and board.
Includes isPersistableWidgetQueryKey filter that excludes widget.app.ping,
and comprehensive tests covering access control, TTL expiry, and size limits.
This commit is contained in:
Thomas Camlong committed 2026-05-29 17:25:38 +02:00
1 parent 598307c7bf
commit b65eef08ea
5 files changed
+276

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
+33
View File
@@ -0,0 +1,33 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheMaxAgeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
+1
View File
@@ -24,6 +24,7 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxAgeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheMaxAgeMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { isPersistableWidgetQueryKey, queryCacheMaxAgeMs, queryCacheMaxValueBytes } from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheMaxAgeMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheMaxAgeMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheMaxAgeMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});