Invalidating cache to force refresh for CRUD

This commit is contained in:
samkul-swe 2025-10-15 17:53:01 -07:00
parent bf0f4e3e68
commit 6bf889b855
7 changed files with 181 additions and 80 deletions

View file

@ -2,7 +2,7 @@ import type { Message } from "@ai-sdk/react";
import { useCallback, useEffect, useState } from "react";
import type { ResearchMode } from "@/components/chat";
import type { Document } from "@/hooks/use-documents";
import { fetchWithCache } from "@/lib/apiCache";
import { fetchWithCache,invalidateCache } from "@/lib/apiCache";
interface UseChatStateProps {
search_space_id: string;
@ -61,6 +61,8 @@ export function useChatAPI({ token, search_space_id }: UseChatAPIProps) {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
revalidate: 30
}
@ -116,6 +118,7 @@ export function useChatAPI({ token, search_space_id }: UseChatAPIProps) {
throw new Error(`Failed to create chat: ${response.statusText}`);
}
invalidateCache('chats');
const data = await response.json();
return data.id;
} catch (err) {
@ -162,6 +165,7 @@ export function useChatAPI({ token, search_space_id }: UseChatAPIProps) {
if (!response.ok) {
throw new Error(`Failed to update chat: ${response.statusText}`);
}
invalidateCache('chats');
} catch (err) {
console.error("Error updating chat:", err);
}

View file

@ -1,3 +1,5 @@
import { invalidateCache, fetchWithCache } from "@/lib/apiCache";
// Types for connector API
export interface ConnectorConfig {
[key: string]: string;
@ -49,45 +51,41 @@ export const ConnectorService = {
throw new Error(errorData.detail || "Failed to create connector");
}
invalidateCache('connectors');
return response.json();
},
// Get all connectors
async getConnectors(skip = 0, limit = 100): Promise<Connector[]> {
const response = await fetch(
return fetchWithCache(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/?skip=${skip}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
revalidate: 60,
tag: 'connectors',
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Failed to fetch connectors");
}
return response.json();
},
// Get a specific connector
async getConnector(connectorId: number): Promise<Connector> {
const response = await fetch(
return fetchWithCache(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
revalidate: 60,
tag: 'connectors'
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Failed to fetch connector");
}
return response.json();
},
// Update a connector
@ -109,6 +107,8 @@ export const ConnectorService = {
throw new Error(errorData.detail || "Failed to update connector");
}
invalidateCache('connectors');
return response.json();
},
@ -128,5 +128,7 @@ export const ConnectorService = {
const errorData = await response.json();
throw new Error(errorData.detail || "Failed to delete connector");
}
invalidateCache('connectors');
},
};
};

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { fetchWithCache } from "@/lib/apiCache";
import { fetchWithCache, invalidateCache } from "@/lib/apiCache";
import { toast } from "sonner";
import { normalizeListResponse } from "@/lib/pagination";
@ -77,9 +77,12 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 30
revalidate: 30,
tag: 'documents'
}
).catch(err => {
toast.error("Failed to fetch documents");
@ -145,9 +148,12 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 15
revalidate: 15,
tag: 'documents'
}
).catch(err => {
toast.error("Failed to search documents");
@ -186,6 +192,8 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
throw new Error("Failed to delete document");
}
invalidateCache('documents');
toast.success("Document deleted successfully");
// Update the local state after successful deletion
setDocuments(documents.filter((doc) => doc.id !== documentId));

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { fetchWithCache } from "@/lib/apiCache";
import { fetchWithCache, invalidateCache } from "@/lib/apiCache";
import { toast } from "sonner";
export interface LLMConfig {
@ -67,9 +67,12 @@ export function useLLMConfigs(searchSpaceId: number | null) {
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 60
revalidate: 60,
tag: 'llmconfigs'
}
).catch(err => {
throw new Error("Failed to fetch LLM configurations");
@ -107,6 +110,8 @@ export function useLLMConfigs(searchSpaceId: number | null) {
throw new Error(errorData.detail || "Failed to create LLM configuration");
}
invalidateCache('llmconfigs');
const newConfig = await response.json();
setLlmConfigs((prev) => [...prev, newConfig]);
toast.success("LLM configuration created successfully");
@ -134,6 +139,8 @@ export function useLLMConfigs(searchSpaceId: number | null) {
throw new Error("Failed to delete LLM configuration");
}
invalidateCache('llmconfigs');
setLlmConfigs((prev) => prev.filter((config) => config.id !== id));
toast.success("LLM configuration deleted successfully");
return true;
@ -166,6 +173,8 @@ export function useLLMConfigs(searchSpaceId: number | null) {
throw new Error(errorData.detail || "Failed to update LLM configuration");
}
invalidateCache('llmconfigs');
const updatedConfig = await response.json();
setLlmConfigs((prev) => prev.map((c) => (c.id === id ? updatedConfig : c)));
toast.success("LLM configuration updated successfully");
@ -206,9 +215,12 @@ export function useLLMPreferences(searchSpaceId: number | null) {
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 120
revalidate: 120,
tag: 'searchspaces'
}
).catch(err => {
throw new Error("Failed to fetch LLM preferences");
@ -252,6 +264,8 @@ export function useLLMPreferences(searchSpaceId: number | null) {
throw new Error(errorData.detail || "Failed to update LLM preferences");
}
invalidateCache('searchspaces');
const updatedPreferences = await response.json();
setPreferences(updatedPreferences);
toast.success("LLM preferences updated successfully");

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { fetchWithCache } from "@/lib/apiCache";
import { useCallback, useEffect, useState } from "react";
import { fetchWithCache, invalidateCache } from "@/lib/apiCache";
import { toast } from "sonner";
interface SearchSpace {
@ -17,65 +17,69 @@ export function useSearchSpaces() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSearchSpaces = async () => {
try {
setLoading(true);
const data = await fetchWithCache(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
method: "GET",
revalidate: 60
}
).catch(err => {
toast.error("Not authenticated");
throw new Error("Not authenticated");
});
setSearchSpaces(data);
setError(null);
} catch (err: any) {
setError(err.message || "Failed to fetch search spaces");
console.error("Error fetching search spaces:", err);
} finally {
setLoading(false);
}
};
fetchSearchSpaces();
}, []);
// Function to refresh the search spaces list
const refreshSearchSpaces = async () => {
setLoading(true);
const fetchSearchSpaces = useCallback(async () => {
try {
const response = await fetch(
setLoading(true);
const data = await fetchWithCache(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 60,
tag: 'searchspaces'
}
);
if (!response.ok) {
).catch(err => {
toast.error("Not authenticated");
throw new Error("Not authenticated");
}
const data = await response.json();
});
setSearchSpaces(data);
setError(null);
} catch (err: any) {
setError(err.message || "Failed to fetch search spaces");
console.error("Error fetching search spaces:", err);
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
fetchSearchSpaces();
}, [fetchSearchSpaces]);
const refreshSearchSpaces = useCallback(async () => {
try {
setLoading(true);
invalidateCache('searchspaces');
const data = await fetchWithCache(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
'Cache-Control': 'no-store, max-age=0, must-revalidate',
'Pragma': 'no-cache'
},
method: "GET",
revalidate: 60,
tag: 'searchspaces'
}
);
setSearchSpaces(data);
setError(null);
} catch (err: any) {
setError(err.message || "Failed to fetch search spaces");
console.error("Error refreshing search spaces:", err);
} finally {
setLoading(false);
}
}, []);
return { searchSpaces, loading, error, refreshSearchSpaces };
}
}

View file

@ -1,5 +1,27 @@
import { toast } from "sonner";
import { fetchWithCache } from "./apiCache";
import { fetchWithCache, invalidateCache, cacheKeys } from "./apiCache";
type CacheTag = keyof typeof cacheKeys;
// Define a mapping of endpoints to cache tags
const ENDPOINT_CACHE_TAGS: Record<string, CacheTag> = {
'api/v1/documents': 'documents',
'api/v1/chats': 'chats',
'api/v1/searchspaces': 'searchspaces',
'api/v1/search-source-connectors': 'connectors',
'api/v1/llm-configs': 'llmconfigs',
'users/me': 'user'
};
// Helper to determine which cache tag to invalidate
function getCacheTagForEndpoint(path: string): CacheTag | undefined {
for (const [endpoint, tag] of Object.entries(ENDPOINT_CACHE_TAGS)) {
if (path.includes(endpoint)) {
return tag as CacheTag; // Explicit cast to ensure type safety
}
}
return undefined;
}
/**
* Custom fetch wrapper that handles authentication and redirects to home page on 401 Unauthorized
@ -108,11 +130,15 @@ export const apiClient = {
...(token && { Authorization: `Bearer ${token}` }),
};
// Determine the appropriate cache tag
const tag = getCacheTagForEndpoint(path);
return await fetchWithCache(url, {
method: "GET",
...options,
headers,
revalidate,
tag
});
} catch (error) {
if (error instanceof Error && error.message.includes('401')) {
@ -149,6 +175,10 @@ export const apiClient = {
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
}
// Invalidate cache after successful mutation
const tag = getCacheTagForEndpoint(path);
if (tag) invalidateCache(tag);
return response.json();
},
@ -176,6 +206,10 @@ export const apiClient = {
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
}
// Invalidate cache after successful mutation
const tag = getCacheTagForEndpoint(path);
if (tag) invalidateCache(tag);
return response.json();
},
@ -197,6 +231,10 @@ export const apiClient = {
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
}
// Invalidate cache after successful mutation
const tag = getCacheTagForEndpoint(path);
if (tag) invalidateCache(tag);
return response.json();
},
};
};

View file

@ -1,11 +1,21 @@
/**
* List of API endpoints that require specific trailing slash handling
*/
export const cacheKeys = {
documents: 0,
chats: 0,
searchspaces: 0,
user: 0,
connectors: 0,
llmconfigs: 0,
config: 0
};
const ENDPOINTS_CONFIG = {
'users/me': false,
'api/v1/chats': false,
'api/v1/chats': true,
'api/v1/documents': true,
'api/v1/searchspaces': true
'api/v1/searchspaces': true,
'api/v1/search-source-connectors': false,
'api/v1/llm-configs': true,
'api/v1/search-spaces': false
};
function isDetailEndpoint(url: string): boolean {
@ -47,18 +57,35 @@ function formatUrlPath(url: string): string {
}
/**
* API client with Next.js caching and careful URL handling
* API client with Next.js caching, URL handling, and cache invalidation
*/
export async function fetchWithCache(url: string, options: RequestInit & {
revalidate?: number | false,
tag?: keyof typeof cacheKeys
} = {}) {
const { revalidate, ...fetchOptions } = options;
const { revalidate, tag, ...fetchOptions } = options;
const formattedUrl = formatUrlPath(url);
let formattedUrl = formatUrlPath(url);
if (tag) {
const separator = formattedUrl.includes('?') ? '&' : '?';
formattedUrl += `${separator}v=${cacheKeys[tag]}`;
}
if (formattedUrl.includes('documents') && formattedUrl.includes('page_size=')) {
const separator = formattedUrl.includes('?') ? '&' : '?';
formattedUrl += `${separator}t=${Date.now()}`;
}
const headers = new Headers(fetchOptions.headers || {});
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
headers.set('Pragma', 'no-cache');
headers.set('Expires', '0');
const response = await fetch(formattedUrl, {
...fetchOptions,
cache: 'force-cache',
headers,
cache: 'force-cache', // Next.js internal cache still works
next: revalidate !== undefined ? { revalidate } : undefined,
});
@ -67,4 +94,8 @@ export async function fetchWithCache(url: string, options: RequestInit & {
}
return response.json();
}
export function invalidateCache(tag: keyof typeof cacheKeys) {
cacheKeys[tag]++;
}