From 184eb67282ecfee8d4412bce531bd65159a3dfca Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 20:55:27 +0900 Subject: [PATCH] feat: add application-owned query cache contract --- package.json | 1 + pnpm-lock.yaml | 18 +++++ .../query-cache/tanstack-query-cache.js | 72 +++++++++++++++++++ src/application/ports/query-cache-port.js | 9 ++- src/contracts/query-keys.js | 36 ++++++++++ tests/unit/query-cache.test.js | 55 ++++++++++++++ 6 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 src/adapters/query-cache/tanstack-query-cache.js create mode 100644 src/contracts/query-keys.js create mode 100644 tests/unit/query-cache.test.js diff --git a/package.json b/package.json index 66ee01d..0ca4c81 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" }, "dependencies": { + "@tanstack/react-query": "5.101.4", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89142c..edec421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: 5.101.4 + version: 5.101.4(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -396,6 +399,14 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1840,6 +1851,13 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 diff --git a/src/adapters/query-cache/tanstack-query-cache.js b/src/adapters/query-cache/tanstack-query-cache.js new file mode 100644 index 0000000..690ecb6 --- /dev/null +++ b/src/adapters/query-cache/tanstack-query-cache.js @@ -0,0 +1,72 @@ +import { QueryClient } from "@tanstack/react-query"; + +import { createFailure } from "../../contracts/errors.js"; + +export const QUERY_CACHE_DEFAULTS = Object.freeze({ + staleTime: 30_000, + gcTime: 300_000, + refetchOnWindowFocus: true, + retry: false, + mutationRetry: false, + persistence: false, +}); + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CACHE_DEFAULTS.staleTime, + gcTime: QUERY_CACHE_DEFAULTS.gcTime, + refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus, + retry: QUERY_CACHE_DEFAULTS.retry, + }, + mutations: { + retry: QUERY_CACHE_DEFAULTS.mutationRetry, + }, + }, + }); +} + +/** + * @param {QueryClient} queryClient + * @returns {import("../../application/ports/query-cache-port.js").QueryCachePort} + */ +export function createQueryCacheAdapter(queryClient) { + return Object.freeze({ + read(key) { + try { + return { ok: true, value: queryClient.getQueryData(key) }; + } catch { + return cacheFailure("read", key); + } + }, + write(key, value) { + try { + queryClient.setQueryData(key, structuredClone(value)); + return { ok: true }; + } catch { + return cacheFailure("write", key); + } + }, + async invalidate(namespace) { + try { + await queryClient.invalidateQueries({ queryKey: namespace, exact: false }); + return { ok: true }; + } catch { + return cacheFailure("invalidate", namespace); + } + }, + }); +} + +/** @param {string} phase @param {readonly unknown[]} key */ +function cacheFailure(phase, key) { + const namespace = typeof key[0] === "string" ? key[0] : "unknown"; + return { + ok: /** @type {false} */ (false), + error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, { + code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`, + causeClass: `namespace:${namespace}`, + }), + }; +} diff --git a/src/application/ports/query-cache-port.js b/src/application/ports/query-cache-port.js index 872d857..f06905b 100644 --- a/src/application/ports/query-cache-port.js +++ b/src/application/ports/query-cache-port.js @@ -1,8 +1,11 @@ /** * @typedef {{ - * read(key: readonly unknown[]): unknown, - * write(key: readonly unknown[], value: unknown): void, - * invalidate(namespace: readonly unknown[]): Promise + * read(key: readonly unknown[]): { ok: true, value: unknown } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, + * write(key: readonly unknown[], value: unknown): { ok: true } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, + * invalidate(namespace: readonly unknown[]): Promise<{ ok: true } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }> * }} QueryCachePort */ diff --git a/src/contracts/query-keys.js b/src/contracts/query-keys.js new file mode 100644 index 0000000..050ac61 --- /dev/null +++ b/src/contracts/query-keys.js @@ -0,0 +1,36 @@ +const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]); + +export const queryKeys = Object.freeze({ + resource: Object.freeze({ + all: () => RESOURCE_NAMESPACE, + list: (filters = {}) => + Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]), + /** @param {string} resourceId */ + detail: (resourceId) => + Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]), + }), +}); + +export const QUERY_REGISTRY = Object.freeze({ + RESOURCE: Object.freeze({ + namespace: RESOURCE_NAMESPACE, + serialization: "canonical-object-order", + identity: "no-pii-token-or-raw-url", + invalidation: "resource namespace after successful mutation", + version: 1, + persistence: "disabled", + }), +}); + +/** @param {unknown} value @returns {unknown} */ +export function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]), + ); + } + return value; +} diff --git a/tests/unit/query-cache.test.js b/tests/unit/query-cache.test.js new file mode 100644 index 0000000..f92732a --- /dev/null +++ b/tests/unit/query-cache.test.js @@ -0,0 +1,55 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +import { + createQueryCacheAdapter, + createQueryClient, +} from "../../src/adapters/query-cache/tanstack-query-cache.js"; +import { queryKeys } from "../../src/contracts/query-keys.js"; + +describe("query key registry", () => { + it("canonicalizes filter order into the same stable key", () => { + expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual( + queryKeys.resource.list({ status: "open", page: 1 }), + ); + }); + + it("contains no raw URL or token material", () => { + expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe( + '["resource",1,"detail","resource-1"]', + ); + }); +}); + +describe("TanStack QueryCachePort adapter", () => { + it("reads, writes, and invalidates only the declared namespace", async () => { + const client = createQueryClient(); + const adapter = createQueryCacheAdapter(client); + const listKey = queryKeys.resource.list({ page: 1 }); + const otherKey = ["other", 1]; + + expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true }); + expect(adapter.write(otherKey, "preserved")).toEqual({ ok: true }); + expect(adapter.read(listKey)).toMatchObject({ + ok: true, + value: [{ id: "resource-1" }], + }); + + await adapter.invalidate(queryKeys.resource.all()); + expect(client.getQueryState(listKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false); + }); + + it("normalizes adapter exceptions without raw key data", async () => { + const client = new QueryClient(); + vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("secret-key")); + const adapter = createQueryCacheAdapter(client); + const result = await adapter.invalidate(["resource", "sensitive-filter"]); + + expect(result).toMatchObject({ + ok: false, + error: { kind: "QUERY_CACHE_FAILURE" }, + }); + expect(JSON.stringify(result)).not.toContain("sensitive-filter"); + }); +});