From ba34c6716f3239341095c49a74ecb21d7695d1dc Mon Sep 17 00:00:00 2001 From: Goodness Date: Mon, 27 Jul 2026 22:07:06 +0100 Subject: [PATCH 1/3] feat: add optimistic preference cache updates --- src/features/settings/preferencesCache.ts | 64 ++++++++ src/features/settings/useUpdatePreferences.ts | 80 +++++++--- src/lib/optimisticCache.ts | 63 ++++++++ src/lib/queryKeys.ts | 8 + tests/hooks/useUpdatePreferences.test.tsx | 145 ++++++++++++++++++ tests/optimisticCache.test.ts | 67 ++++++++ 6 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 src/features/settings/preferencesCache.ts create mode 100644 src/lib/optimisticCache.ts create mode 100644 tests/hooks/useUpdatePreferences.test.tsx create mode 100644 tests/optimisticCache.test.ts diff --git a/src/features/settings/preferencesCache.ts b/src/features/settings/preferencesCache.ts new file mode 100644 index 0000000..b2da7d9 --- /dev/null +++ b/src/features/settings/preferencesCache.ts @@ -0,0 +1,64 @@ +import { queryKeys } from "../../lib/queryKeys"; + +export interface PreferencesPayload { + pushNotifications: boolean; + emailNotifications: boolean; +} + +export type PreferencesSyncStatus = "synced" | "pending" | "error"; + +export interface CachedPreferences extends PreferencesPayload { + syncStatus: PreferencesSyncStatus; + updatedAt: number; + error?: string; +} + +export const preferencesQueryKey = queryKeys.preferences.current; + +export const DEFAULT_PREFERENCES: CachedPreferences = { + pushNotifications: false, + emailNotifications: false, + syncStatus: "synced", + updatedAt: 0, +}; + +export function buildOptimisticPreferences( + current: CachedPreferences | undefined, + payload: PreferencesPayload, + now: number = Date.now(), +): CachedPreferences { + return { + ...(current ?? DEFAULT_PREFERENCES), + ...payload, + syncStatus: "pending", + updatedAt: now, + error: undefined, + }; +} + +export function buildSyncedPreferences( + current: CachedPreferences | undefined, + payload: PreferencesPayload, + now: number = Date.now(), +): CachedPreferences { + return { + ...(current ?? DEFAULT_PREFERENCES), + ...payload, + syncStatus: "synced", + updatedAt: now, + error: undefined, + }; +} + +export function buildErroredPreferences( + current: CachedPreferences | undefined, + error: Error, + now: number = Date.now(), +): CachedPreferences { + return { + ...(current ?? DEFAULT_PREFERENCES), + syncStatus: "error", + updatedAt: now, + error: error.message, + }; +} diff --git a/src/features/settings/useUpdatePreferences.ts b/src/features/settings/useUpdatePreferences.ts index eab0b7f..b405b7f 100644 --- a/src/features/settings/useUpdatePreferences.ts +++ b/src/features/settings/useUpdatePreferences.ts @@ -1,33 +1,67 @@ import { useOfflineMutation } from "../offline/useOfflineMutation"; import { MutationType } from "../offline/mutationQueue"; -import { queryClient } from "../../lib/queryClient"; +import { useQueryClient } from "@tanstack/react-query"; +import { + applyOptimisticCacheUpdates, + rollbackOptimisticCacheUpdates, + type OptimisticMutationContext, +} from "../../lib/optimisticCache"; +import { + buildOptimisticPreferences, + buildSyncedPreferences, + preferencesQueryKey, + type CachedPreferences, + type PreferencesPayload, +} from "./preferencesCache"; -export interface PreferencesPayload { - pushNotifications: boolean; - emailNotifications: boolean; +export type { CachedPreferences, PreferencesPayload } from "./preferencesCache"; + +export type UpdatePreferencesTransport = ( + payload: PreferencesPayload, +) => Promise; + +export interface UpdatePreferencesOptions { + updatePreferences?: UpdatePreferencesTransport; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function defaultUpdatePreferences(payload: PreferencesPayload): Promise { + if ((payload as PreferencesPayload & { _simulateConflict?: boolean })._simulateConflict) { + throw new Error("HTTP 409 Conflict: Preferences modified elsewhere."); + } + + await sleep(1000); + return buildSyncedPreferences(undefined, payload); } -export function useUpdatePreferences() { - return useOfflineMutation({ +export function useUpdatePreferences(options: UpdatePreferencesOptions = {}) { + const queryClient = useQueryClient(); + const updatePreferences = options.updatePreferences ?? defaultUpdatePreferences; + + return useOfflineMutation< + CachedPreferences, + Error, + PreferencesPayload, + OptimisticMutationContext + >({ mutationType: MutationType.UPDATE_NOTIFICATION_PREFERENCES, - mutationFn: async (payload) => { - // Simulate network request. - // This will automatically fail and queue if offline due to networkMode: "offlineFirst". - - // We can simulate a conflict for testing purposes if payload has a special flag. - if ((payload as any)._simulateConflict) { - throw new Error("HTTP 409 Conflict: Preferences modified elsewhere."); - } - - // Simulate delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Success - return; + mutationKey: preferencesQueryKey, + mutationFn: updatePreferences, + onMutate: async (payload) => { + return applyOptimisticCacheUpdates(queryClient, payload, [ + { + queryKey: preferencesQueryKey, + updater: (current, variables) => + buildOptimisticPreferences(current as CachedPreferences | undefined, variables), + }, + ]); + }, + onError: (_error, _variables, context) => { + rollbackOptimisticCacheUpdates(queryClient, context); }, - onSuccess: () => { - // Typically we'd invalidate a query here. - // queryClient.invalidateQueries({ queryKey: ["preferences"] }); + onSuccess: (preferences) => { + queryClient.setQueryData(preferencesQueryKey, preferences); }, }); } diff --git a/src/lib/optimisticCache.ts b/src/lib/optimisticCache.ts new file mode 100644 index 0000000..2f6a454 --- /dev/null +++ b/src/lib/optimisticCache.ts @@ -0,0 +1,63 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +export interface OptimisticCachePatch { + queryKey: QueryKey; + updater: (current: unknown, variables: TVariables) => unknown; +} + +export interface OptimisticCacheSnapshot { + queryKey: QueryKey; + previousData: unknown; + hadQuery: boolean; +} + +export interface OptimisticMutationContext { + snapshots: OptimisticCacheSnapshot[]; +} + +export async function applyOptimisticCacheUpdates( + queryClient: QueryClient, + variables: TVariables, + patches: readonly OptimisticCachePatch[], +): Promise { + const snapshots: OptimisticCacheSnapshot[] = []; + + for (const patch of patches) { + await queryClient.cancelQueries({ queryKey: patch.queryKey, exact: true }); + + snapshots.push({ + queryKey: patch.queryKey, + previousData: queryClient.getQueryData(patch.queryKey), + hadQuery: queryClient.getQueryState(patch.queryKey) !== undefined, + }); + + queryClient.setQueryData(patch.queryKey, (current) => patch.updater(current, variables)); + } + + return { snapshots }; +} + +export function rollbackOptimisticCacheUpdates( + queryClient: QueryClient, + context: OptimisticMutationContext | undefined, +): void { + if (!context) return; + + for (const snapshot of context.snapshots) { + if (snapshot.hadQuery) { + queryClient.setQueryData(snapshot.queryKey, snapshot.previousData); + } else { + queryClient.removeQueries({ queryKey: snapshot.queryKey, exact: true }); + } + } +} + +export function markOptimisticCacheSynced( + queryClient: QueryClient, + queryKey: QueryKey, + updater: (current: TData | undefined) => TData, +): TData { + const next = updater(queryClient.getQueryData(queryKey)); + queryClient.setQueryData(queryKey, next); + return next; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 2a49a9d..a703dbb 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -9,6 +9,7 @@ export const QUERY_ROOTS = { GUILD_CONFIG: "guild-config", GUILD_ROLES: "guild-roles", ACCESS_CHECK: "access-check", + PREFERENCES: "preferences", PROFILE: "profile", USER_PROFILE: "user-profile", } as const; @@ -48,6 +49,12 @@ export const queryKeys = { }, accessCheck: { all: ["access-check"] as const, + byParams: (walletAddress: string, guildId: string, resourceId: string) => + ["access-check", walletAddress, guildId, resourceId] as const, + }, + preferences: { + all: ["preferences"] as const, + current: ["preferences", "current"] as const, }, profile: { all: ["profile"] as const, @@ -66,6 +73,7 @@ export const PERSISTABLE_QUERY_ROOTS: readonly QueryRoot[] = [ QUERY_ROOTS.GUILD_CONFIG, QUERY_ROOTS.GUILD_ROLES, QUERY_ROOTS.ACCESS_CHECK, + QUERY_ROOTS.PREFERENCES, ]; export function isPersistableQuery(queryKey: readonly unknown[]): boolean { diff --git a/tests/hooks/useUpdatePreferences.test.tsx b/tests/hooks/useUpdatePreferences.test.tsx new file mode 100644 index 0000000..3e8300b --- /dev/null +++ b/tests/hooks/useUpdatePreferences.test.tsx @@ -0,0 +1,145 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import TestRenderer, { act } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + useUpdatePreferences, + type CachedPreferences, + type PreferencesPayload, +} from "../../src/features/settings/useUpdatePreferences"; +import { + buildSyncedPreferences, + preferencesQueryKey, +} from "../../src/features/settings/preferencesCache"; + +type UpdatePreferencesMutation = ReturnType; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function renderUpdatePreferencesHook( + updatePreferences: (payload: PreferencesPayload) => Promise, +) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + let hookValue: UpdatePreferencesMutation | null = null; + + const HookHarness = () => { + hookValue = useUpdatePreferences({ updatePreferences }); + return null; + }; + + TestRenderer.create( + + + , + ); + + return { + queryClient, + get current() { + if (!hookValue) { + throw new Error("Hook did not render"); + } + return hookValue; + }, + }; +} + +describe("useUpdatePreferences optimistic cache updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates the preferences cache immediately before the transport resolves", async () => { + const request = deferred(); + const transport = vi.fn(() => request.promise); + const { current, queryClient } = renderUpdatePreferencesHook(transport); + const previous = buildSyncedPreferences(undefined, { + pushNotifications: false, + emailNotifications: true, + }); + const payload = { + pushNotifications: true, + emailNotifications: false, + }; + + queryClient.setQueryData(preferencesQueryKey, previous); + + act(() => { + current.mutate(payload); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(transport.mock.calls[0][0]).toStrictEqual(payload); + expect(queryClient.getQueryData(preferencesQueryKey)).toMatchObject({ + pushNotifications: true, + emailNotifications: false, + syncStatus: "pending", + }); + + const synced = buildSyncedPreferences(undefined, payload, 12345); + await act(async () => { + request.resolve(synced); + await request.promise; + await Promise.resolve(); + }); + + expect(queryClient.getQueryData(preferencesQueryKey)).toStrictEqual(synced); + }); + + it("rolls back to the previous preferences when the mutation fails", async () => { + const transport = vi.fn(async () => { + throw new Error("server rejected preferences"); + }); + const { current, queryClient } = renderUpdatePreferencesHook(transport); + const previous = buildSyncedPreferences(undefined, { + pushNotifications: false, + emailNotifications: true, + }); + + queryClient.setQueryData(preferencesQueryKey, previous); + + await act(async () => { + await expect( + current.mutateAsync({ + pushNotifications: true, + emailNotifications: false, + }), + ).rejects.toThrow("server rejected preferences"); + }); + + expect(queryClient.getQueryData(preferencesQueryKey)).toStrictEqual(previous); + }); + + it("removes an optimistic-only preferences cache entry when creation fails", async () => { + const transport = vi.fn(async () => { + throw new Error("offline queue rejected preferences"); + }); + const { current, queryClient } = renderUpdatePreferencesHook(transport); + + await act(async () => { + await expect( + current.mutateAsync({ + pushNotifications: true, + emailNotifications: true, + }), + ).rejects.toThrow("offline queue rejected preferences"); + }); + + expect(queryClient.getQueryState(preferencesQueryKey)).toBeUndefined(); + }); +}); diff --git a/tests/optimisticCache.test.ts b/tests/optimisticCache.test.ts new file mode 100644 index 0000000..0095c28 --- /dev/null +++ b/tests/optimisticCache.test.ts @@ -0,0 +1,67 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { + applyOptimisticCacheUpdates, + markOptimisticCacheSynced, + rollbackOptimisticCacheUpdates, +} from "../src/lib/optimisticCache"; + +describe("optimistic cache helpers", () => { + it("applies optimistic data and rolls back to the previous cache value", async () => { + const queryClient = new QueryClient(); + const queryKey = ["preferences", "current"] as const; + const previous = { pushNotifications: false, emailNotifications: true }; + + queryClient.setQueryData(queryKey, previous); + + const context = await applyOptimisticCacheUpdates(queryClient, { pushNotifications: true }, [ + { + queryKey, + updater: (current, variables) => ({ + ...(current as typeof previous), + ...variables, + }), + }, + ]); + + expect(queryClient.getQueryData(queryKey)).toStrictEqual({ + pushNotifications: true, + emailNotifications: true, + }); + + rollbackOptimisticCacheUpdates(queryClient, context); + + expect(queryClient.getQueryData(queryKey)).toStrictEqual(previous); + }); + + it("removes a query created only for an optimistic update when rollback runs", async () => { + const queryClient = new QueryClient(); + const queryKey = ["preferences", "current"] as const; + + const context = await applyOptimisticCacheUpdates(queryClient, { pushNotifications: true }, [ + { + queryKey, + updater: (_current, variables) => variables, + }, + ]); + + expect(queryClient.getQueryData(queryKey)).toStrictEqual({ pushNotifications: true }); + + rollbackOptimisticCacheUpdates(queryClient, context); + + expect(queryClient.getQueryState(queryKey)).toBeUndefined(); + }); + + it("writes a confirmed mutation result without invalidating unrelated queries", () => { + const queryClient = new QueryClient(); + const queryKey = ["preferences", "current"] as const; + queryClient.setQueryData(queryKey, { syncStatus: "pending" }); + + const synced = markOptimisticCacheSynced(queryClient, queryKey, () => ({ + syncStatus: "synced", + })); + + expect(synced).toStrictEqual({ syncStatus: "synced" }); + expect(queryClient.getQueryData(queryKey)).toStrictEqual({ syncStatus: "synced" }); + }); +}); From 68fa9605b64094dc99bbd9b3596d8de836a6aef9 Mon Sep 17 00:00:00 2001 From: Goodness Date: Mon, 27 Jul 2026 22:07:25 +0100 Subject: [PATCH 2/3] feat: synchronize mutation result caches --- src/features/access/attestationIntegration.ts | 28 ++++- src/features/access/useAccessCheck.ts | 28 +++-- .../attestation/attestationQueryCache.ts | 106 ++++++++++++++++++ src/features/attestation/useAttestations.ts | 32 ++++-- tests/attestationQueryCache.test.ts | 104 +++++++++++++++++ tests/hooks/useAccessCheckMutation.test.ts | 11 ++ 6 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 src/features/attestation/attestationQueryCache.ts create mode 100644 tests/attestationQueryCache.test.ts diff --git a/src/features/access/attestationIntegration.ts b/src/features/access/attestationIntegration.ts index ba50ee1..5b9ed76 100644 --- a/src/features/access/attestationIntegration.ts +++ b/src/features/access/attestationIntegration.ts @@ -3,10 +3,12 @@ * Augments access check results with cryptographic proof validation */ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { guildPassClient } from "../../lib/guildpassClient"; +import { queryKeys } from "../../lib/queryKeys"; import type { AccessCheckParams, AccessCheckResult } from "./useAccessCheck"; import type { AttestationService } from "../attestation/attestationService"; +import { writeVerifiedAttestationToCache } from "../attestation/attestationQueryCache"; /** * Enhanced access check result with attestation verification @@ -27,6 +29,8 @@ export interface AttestationAugmentedAccessCheck extends AccessCheckResult { * @returns Mutation function for access checking */ export function useAccessCheckWithAttestations(attestationService: AttestationService | null) { + const queryClient = useQueryClient(); + return useMutation({ mutationKey: ["access-check-with-attestations"], mutationFn: async (params: AccessCheckParams) => { @@ -59,6 +63,12 @@ export function useAccessCheckWithAttestations(attestationService: AttestationSe }; }, networkMode: "offlineFirst", + onSuccess: (result, params) => { + queryClient.setQueryData( + queryKeys.accessCheck.byParams(params.walletAddress, params.guildId, params.resourceId), + result, + ); + }, }); } @@ -70,7 +80,10 @@ export function useAccessCheckWithAttestations(attestationService: AttestationSe * @returns Mutation function */ export function useCacheAccessAttestationsMutation(attestationService: AttestationService | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["cache-access-attestations"], mutationFn: async (params: { walletAddress: string; guildId: string; @@ -101,5 +114,18 @@ export function useCacheAccessAttestationsMutation(attestationService: Attestati return results; }, + onSuccess: (results, params) => { + for (const result of results) { + writeVerifiedAttestationToCache( + queryClient, + { + walletAddress: params.walletAddress, + guildId: params.guildId, + roleId: "access-" + result.resourceId, + }, + result, + ); + } + }, }); } diff --git a/src/features/access/useAccessCheck.ts b/src/features/access/useAccessCheck.ts index 346a3b7..a949352 100644 --- a/src/features/access/useAccessCheck.ts +++ b/src/features/access/useAccessCheck.ts @@ -5,14 +5,12 @@ import { guildPassClient } from "../../lib/guildpassClient"; import { useMultiChainRoleEligibility } from "./useMultiChainRoleEligibility"; import type { PerChainRoleEligibilityResolution } from "./roleEligibilityResolver"; import { useNetworkStatus } from "../offline/useNetworkStatus"; -import { - resolveAccessDecision, - type AccessDecisionConfidence, -} from "./accessDecisionPipeline"; +import { resolveAccessDecision, type AccessDecisionConfidence } from "./accessDecisionPipeline"; import { getCachedOfflineVerificationInputs, verifyOfflineCredentialAccess, } from "./offlineCredentialVerifier"; +import { queryKeys } from "../../lib/queryKeys"; type AccessCheckMutateOptions = MutateOptions; @@ -103,7 +101,7 @@ export const useAccessCheck = () => { ); const mutation = useMutation({ - mutationKey: ["access-check"], + mutationKey: queryKeys.accessCheck.all, mutationFn: async (params) => { const decision = await resolveAccessDecision({ walletAddress: params.walletAddress, @@ -151,22 +149,22 @@ export const useAccessCheck = () => { discrepancy: decision.discrepancy, }; }, - onSuccess: (result) => { + onSuccess: (result, variables) => { + queryClient.setQueryData( + queryKeys.accessCheck.byParams( + variables.walletAddress, + variables.guildId, + variables.resourceId, + ), + result, + ); dispatch({ type: "SUBMIT_SUCCESS", result }); }, onError: (error: Error) => { dispatch({ type: "SUBMIT_ERROR", error: error.message }); }, }); - const { - data, - error, - isPending, - isError, - mutate, - mutateAsync, - reset: resetMutation, - } = mutation; + const { data, error, isPending, isError, mutate, mutateAsync, reset: resetMutation } = mutation; const startScan = useCallback(() => { dispatch({ type: "START_SCAN" }); diff --git a/src/features/attestation/attestationQueryCache.ts b/src/features/attestation/attestationQueryCache.ts new file mode 100644 index 0000000..ab6c44c --- /dev/null +++ b/src/features/attestation/attestationQueryCache.ts @@ -0,0 +1,106 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import type { GuildIssuerKey, RoleAttestation } from "./types"; + +export interface AttestationVerificationCacheResult { + valid: boolean; + attestation?: RoleAttestation; + error?: string; + validityStatus?: string; +} + +export interface AttestationCacheParams { + walletAddress: string; + guildId: string; + roleId: string; +} + +export const attestationQueryKeys = { + verification: (walletAddress: string | null, guildId: string | null, roleId: string | null) => + ["attestation", walletAddress, guildId, roleId] as const, + localVerification: ( + walletAddress: string | null, + guildId: string | null, + roleId: string | null, + ) => ["local-attestation-verification", walletAddress, guildId, roleId] as const, + cachedExists: (walletAddress: string | null, guildId: string | null, roleId: string | null) => + ["cached-attestation-exists", walletAddress, guildId, roleId] as const, + cachedForGuild: (walletAddress: string | null, guildId: string | null) => + ["cached-attestations-guild", walletAddress, guildId] as const, + issuerKey: (guildId: string) => ["attestation-issuer-key", guildId] as const, + issuerKeyRefresh: ["attestation-issuer-key-refresh"] as const, +}; + +function upsertAttestation( + current: RoleAttestation[] | undefined, + next: RoleAttestation, +): RoleAttestation[] { + const existing = current ?? []; + return [...existing.filter((attestation) => attestation.roleId !== next.roleId), next]; +} + +function removeAttestation( + current: RoleAttestation[] | undefined, + roleId: string, +): RoleAttestation[] { + return (current ?? []).filter((attestation) => attestation.roleId !== roleId); +} + +export function writeVerifiedAttestationToCache( + queryClient: QueryClient, + params: AttestationCacheParams, + result: AttestationVerificationCacheResult, +): void { + queryClient.setQueryData( + attestationQueryKeys.verification(params.walletAddress, params.guildId, params.roleId), + result, + ); + queryClient.setQueryData( + attestationQueryKeys.cachedExists(params.walletAddress, params.guildId, params.roleId), + result.valid && !!result.attestation, + ); + queryClient.setQueryData( + attestationQueryKeys.cachedForGuild(params.walletAddress, params.guildId), + (current) => + result.valid && result.attestation + ? upsertAttestation(current, result.attestation) + : removeAttestation(current, params.roleId), + ); +} + +export function writeIssuerKeyToCache( + queryClient: QueryClient, + guildId: string, + issuerAddress: `0x${string}`, + now: number = Date.now(), +): GuildIssuerKey { + const issuerKey: GuildIssuerKey = { + guildId, + issuerAddress, + registeredAt: Math.floor(now / 1000), + cachedAt: now, + }; + + queryClient.setQueryData(attestationQueryKeys.issuerKey(guildId), issuerKey); + return issuerKey; +} + +function isAttestationQueryForGuild(queryKey: QueryKey, guildId: string): boolean { + const root = queryKey[0]; + return ( + (root === "attestation" || + root === "local-attestation-verification" || + root === "cached-attestation-exists" || + root === "cached-attestations-guild") && + queryKey.includes(guildId) + ); +} + +export async function invalidateAttestationQueriesForGuild( + queryClient: QueryClient, + guildId: string, +): Promise { + await queryClient.invalidateQueries({ + predicate: (query) => isAttestationQueryForGuild(query.queryKey, guildId), + refetchType: "active", + }); +} diff --git a/src/features/attestation/useAttestations.ts b/src/features/attestation/useAttestations.ts index c13e08f..0f85636 100644 --- a/src/features/attestation/useAttestations.ts +++ b/src/features/attestation/useAttestations.ts @@ -3,12 +3,17 @@ * Integrates attestation logic with React Query for data fetching and caching */ -import { useQuery, useMutation } from "@tanstack/react-query"; -import { useCallback } from "react"; -import type { RoleAttestation, AttestationValidationResult } from "./types"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { RoleAttestation } from "./types"; import type { AttestationService } from "./attestationService"; import { validateAttestation, getAttestationValidityStatus } from "./verifySignature"; import { getCachedIssuerKey } from "./issuerKeyRegistry"; +import { + attestationQueryKeys, + invalidateAttestationQueriesForGuild, + writeIssuerKeyToCache, + writeVerifiedAttestationToCache, +} from "./attestationQueryCache"; /** * Hook to fetch and verify an attestation @@ -27,7 +32,7 @@ export function useAttestationVerification( roleId: string | null, ) { return useQuery({ - queryKey: ["attestation", walletAddress, guildId, roleId], + queryKey: attestationQueryKeys.verification(walletAddress, guildId, roleId), queryFn: async () => { if (!service || !walletAddress || !guildId || !roleId) { throw new Error("Missing required parameters"); @@ -58,7 +63,7 @@ export function useLocalAttestationVerification( roleId: string | null, ) { return useQuery({ - queryKey: ["local-attestation-verification", walletAddress, guildId, roleId], + queryKey: attestationQueryKeys.localVerification(walletAddress, guildId, roleId), queryFn: async () => { if (!service || !walletAddress || !guildId || !roleId) { throw new Error("Missing required parameters"); @@ -88,7 +93,7 @@ export function useCachedAttestationExists( roleId: string | null, ) { return useQuery({ - queryKey: ["cached-attestation-exists", walletAddress, guildId, roleId], + queryKey: attestationQueryKeys.cachedExists(walletAddress, guildId, roleId), queryFn: async () => { if (!service || !walletAddress || !guildId || !roleId) { return false; @@ -116,7 +121,7 @@ export function useCachedAttestationsForGuild( guildId: string | null, ) { return useQuery({ - queryKey: ["cached-attestations-guild", walletAddress, guildId], + queryKey: attestationQueryKeys.cachedForGuild(walletAddress, guildId), queryFn: async () => { if (!service || !walletAddress || !guildId) { return []; @@ -138,7 +143,10 @@ export function useCachedAttestationsForGuild( * @returns Mutation result */ export function useRefreshIssuerKey(service: AttestationService | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: attestationQueryKeys.issuerKeyRefresh, mutationFn: async (guildId: string) => { if (!service) { throw new Error("Service not initialized"); @@ -146,6 +154,10 @@ export function useRefreshIssuerKey(service: AttestationService | null) { return service.refreshIssuerKey(guildId); }, + onSuccess: (issuerAddress, guildId) => { + writeIssuerKeyToCache(queryClient, guildId, issuerAddress); + void invalidateAttestationQueriesForGuild(queryClient, guildId); + }, }); } @@ -206,7 +218,10 @@ export function useAttestationValidityStatus(attestation: RoleAttestation | null * @returns Mutation result */ export function useFetchAttestationMutation(service: AttestationService | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["fetch-attestation"], mutationFn: async (params: { walletAddress: string; guildId: string; roleId: string }) => { if (!service) { throw new Error("Service not initialized"); @@ -214,5 +229,8 @@ export function useFetchAttestationMutation(service: AttestationService | null) return service.fetchAndVerifyAttestation(params.walletAddress, params.guildId, params.roleId); }, + onSuccess: (result, params) => { + writeVerifiedAttestationToCache(queryClient, params, result); + }, }); } diff --git a/tests/attestationQueryCache.test.ts b/tests/attestationQueryCache.test.ts new file mode 100644 index 0000000..613935b --- /dev/null +++ b/tests/attestationQueryCache.test.ts @@ -0,0 +1,104 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { + attestationQueryKeys, + writeIssuerKeyToCache, + writeVerifiedAttestationToCache, +} from "../src/features/attestation/attestationQueryCache"; +import type { RoleAttestation } from "../src/features/attestation/types"; + +const walletAddress = "0x1234567890123456789012345678901234567890"; +const guildId = "guild-alpha"; + +function attestation(roleId: string, expiresAt = 2_000_000_000): RoleAttestation { + return { + guildId, + roleId, + wallet: walletAddress, + issuedAt: 1_700_000_000, + expiresAt, + signature: ("0x" + "a".repeat(130)) as `0x${string}`, + }; +} + +describe("attestation query cache synchronization", () => { + it("writes a verified attestation to exact and aggregate cache entries", () => { + const queryClient = new QueryClient(); + const roleAttestation = attestation("member"); + + writeVerifiedAttestationToCache( + queryClient, + { walletAddress, guildId, roleId: "member" }, + { valid: true, attestation: roleAttestation, validityStatus: "Valid" }, + ); + + expect( + queryClient.getQueryData(attestationQueryKeys.verification(walletAddress, guildId, "member")), + ).toStrictEqual({ + valid: true, + attestation: roleAttestation, + validityStatus: "Valid", + }); + expect( + queryClient.getQueryData(attestationQueryKeys.cachedExists(walletAddress, guildId, "member")), + ).toBe(true); + expect( + queryClient.getQueryData(attestationQueryKeys.cachedForGuild(walletAddress, guildId)), + ).toStrictEqual([roleAttestation]); + }); + + it("deduplicates role attestations when refreshed data arrives", () => { + const queryClient = new QueryClient(); + const older = attestation("member", 2_000_000_000); + const newer = attestation("member", 2_100_000_000); + + queryClient.setQueryData(attestationQueryKeys.cachedForGuild(walletAddress, guildId), [older]); + + writeVerifiedAttestationToCache( + queryClient, + { walletAddress, guildId, roleId: "member" }, + { valid: true, attestation: newer }, + ); + + expect( + queryClient.getQueryData(attestationQueryKeys.cachedForGuild(walletAddress, guildId)), + ).toStrictEqual([newer]); + }); + + it("removes a stale aggregate entry when the authoritative result is invalid", () => { + const queryClient = new QueryClient(); + const stale = attestation("member"); + + queryClient.setQueryData(attestationQueryKeys.cachedForGuild(walletAddress, guildId), [stale]); + + writeVerifiedAttestationToCache( + queryClient, + { walletAddress, guildId, roleId: "member" }, + { valid: false, error: "Attestation revoked" }, + ); + + expect( + queryClient.getQueryData(attestationQueryKeys.cachedExists(walletAddress, guildId, "member")), + ).toBe(false); + expect( + queryClient.getQueryData(attestationQueryKeys.cachedForGuild(walletAddress, guildId)), + ).toStrictEqual([]); + }); + + it("writes refreshed issuer keys to a targeted guild cache entry", () => { + const queryClient = new QueryClient(); + const issuerAddress = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + + const issuerKey = writeIssuerKeyToCache(queryClient, guildId, issuerAddress, 1_700_000_000_000); + + expect(issuerKey).toStrictEqual({ + guildId, + issuerAddress, + registeredAt: 1_700_000_000, + cachedAt: 1_700_000_000_000, + }); + expect(queryClient.getQueryData(attestationQueryKeys.issuerKey(guildId))).toStrictEqual( + issuerKey, + ); + }); +}); diff --git a/tests/hooks/useAccessCheckMutation.test.ts b/tests/hooks/useAccessCheckMutation.test.ts index 087289c..987ccd9 100644 --- a/tests/hooks/useAccessCheckMutation.test.ts +++ b/tests/hooks/useAccessCheckMutation.test.ts @@ -4,6 +4,7 @@ import TestRenderer, { act } from "react-test-renderer"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ACCESS_CHECK_PARAMS, ACCESS_GRANTED_FIXTURE } from "../fixtures/access.fixtures"; import { useAccessCheck } from "../../src/features/access/useAccessCheck"; +import { queryKeys } from "../../src/lib/queryKeys"; const guildPassClientMock = vi.hoisted(() => ({ checkAccess: vi.fn(), @@ -53,6 +54,7 @@ function renderAccessCheckHook() { ); return { + queryClient, get current() { if (!hookValue) { throw new Error("Hook did not render"); @@ -87,6 +89,15 @@ describe("useAccessCheck mutation flow", () => { expect(res.syncStatus).toBe("confirmed_online"); expect(guildPassClientMock.checkAccess).toHaveBeenCalledTimes(1); expect(guildPassClientMock.checkAccess).toHaveBeenCalledWith(ACCESS_CHECK_PARAMS); + expect( + result.queryClient.getQueryData( + queryKeys.accessCheck.byParams( + ACCESS_CHECK_PARAMS.walletAddress, + ACCESS_CHECK_PARAMS.guildId, + ACCESS_CHECK_PARAMS.resourceId, + ), + ), + ).toMatchObject(ACCESS_GRANTED_FIXTURE); }); it("uses the access-check mutation key", async () => { From 3cfae24e3765b12d1f5ad4ff10da2bbfbbc735fe Mon Sep 17 00:00:00 2001 From: Goodness Date: Mon, 27 Jul 2026 22:07:40 +0100 Subject: [PATCH 3/3] docs: document optimistic update patterns --- docs/optimistic-updates.md | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/optimistic-updates.md diff --git a/docs/optimistic-updates.md b/docs/optimistic-updates.md new file mode 100644 index 0000000..28ac37c --- /dev/null +++ b/docs/optimistic-updates.md @@ -0,0 +1,41 @@ +# Optimistic Updates + +GuildPass uses TanStack Query for server state and should prefer optimistic +updates only when the user action is reversible and the app can restore the +previous cache value on failure. + +## When to use optimism + +Use an optimistic cache update for low-risk user preferences, drafts, and other +queueable mutations where the submitted payload is the expected final state. + +Do not optimistically grant credentials, access decisions, membership status, or +role ownership. Those values are security-sensitive and must be shown only after +the backend, local credential verifier, or sync engine produces an authoritative +result. + +## Pattern + +1. Give every affected view a stable query key in `src/lib/queryKeys.ts`. +2. In `onMutate`, cancel the exact affected queries, snapshot the current data, + and write the optimistic value with `applyOptimisticCacheUpdates`. +3. In `onError`, call `rollbackOptimisticCacheUpdates` with the context returned + from `onMutate`. +4. In `onSuccess`, write the authoritative response into the exact cache entry. + Avoid broad invalidation when the response already contains the final state. +5. Use targeted invalidation only when the mutation changes data that must be + recomputed by active queries, such as an issuer-key refresh invalidating + active attestation checks for one guild. + +## Current behavior + +Preference updates write a pending cache entry immediately and roll back to the +previous preferences when the mutation fails. + +Access checks are confirmed-only. Successful checks are written to +`queryKeys.accessCheck.byParams(walletAddress, guildId, resourceId)` so any +screen reading the same result can reuse it without an extra request. + +Attestation fetches write exact verification, existence, and per-guild aggregate +queries from the verified result. Invalid verified results remove stale aggregate +entries for the affected role.