From b65eef08eaf0dcab46271d3894975502fbf344a4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 17:25:38 +0200 Subject: [PATCH] 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. --- packages/api/package.json | 1 + packages/api/src/query-cache.ts | 33 ++++ packages/api/src/root.ts | 1 + packages/api/src/router/query-cache.ts | 54 +++++++ packages/api/src/test/query-cache.spec.ts | 187 ++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 packages/api/src/query-cache.ts create mode 100644 packages/api/src/router/query-cache.ts create mode 100644 packages/api/src/test/query-cache.spec.ts diff --git a/packages/api/package.json b/packages/api/package.json index 847279d59..86ed8f1dd 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts new file mode 100644 index 000000000..d6e19f618 --- /dev/null +++ b/packages/api/src/query-cache.ts @@ -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(".")); +}; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a014b4b75..2c785e6ff 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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)), diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts new file mode 100644 index 000000000..d79de2711 --- /dev/null +++ b/packages/api/src/router/query-cache.ts @@ -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, userId: string) => + createQueryCacheChannel({ + userId, + boardId: input.boardId, + key: input.key, + ttlMs: queryCacheMaxAgeMs, + maxValueBytes: queryCacheMaxValueBytes, + }); + +const validateBoardAccessAsync = async (ctx: Parameters[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(); + }), +}); diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts new file mode 100644 index 000000000..aba9fbf42 --- /dev/null +++ b/packages/api/src/test/query-cache.spec.ts @@ -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(); + 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(); + }); +});