mono/packages/ui/src/lib/db.ts

219 lines
9.1 KiB
TypeScript

import { queryClient } from './queryClient';
import { userManager } from './oidc';
// Deprecated: Caching now handled by React Query
// Keeping for backward compatibility
type CacheStorageType = 'memory' | 'local';
/**
* Parse a flat string key into a hierarchical query key array.
* This enables SSE cascade invalidation via StreamInvalidator:
* invalidateQueries({ queryKey: ['posts'] }) matches ['posts', 'abc123']
*/
const KEY_PREFIXES: [RegExp, (...groups: string[]) => string[]][] = [
[/^feed-(.+)$/, (_, id) => ['feed', id]],
[/^full-post-(.+)$/, (_, id) => ['posts', 'full', id]],
[/^post-(.+)$/, (_, id) => ['posts', id]],
[/^like-(.+)-(.+)$/, (_, uid, pid) => ['likes', uid, pid]],
[/^selected-versions-(.+)$/, (_, ids) => ['pictures', 'selected-versions', ids]],
[/^versions-(.+)$/, (_, rest) => ['pictures', 'versions', rest]],
[/^picture-(.+)$/, (_, id) => ['pictures', id]],
[/^pictures-(.+)$/, (_, rest) => ['pictures', rest]],
[/^user-page-(.+?)-(.+)$/, (_, uid, slug) => ['pages', 'user-page', uid, slug]],
[/^user-pages-(.+)$/, (_, uid) => ['pages', 'user-pages', uid]],
[/^page-details-(.+)$/, (_, id) => ['pages', 'details', id]],
[/^page-(.+)$/, (_, id) => ['pages', id]],
[/^pages-(.+)$/, (_, rest) => ['pages', rest]],
[/^profile-(.+)$/, (_, id) => ['users', id]],
[/^settings-(.+)$/, (_, id) => ['users', 'settings', id]],
[/^roles-(.+)$/, (_, id) => ['users', 'roles', id]],
[/^openai-(.+)$/, (_, id) => ['users', 'openai', id]],
[/^api-keys-(.+)$/, (_, id) => ['users', 'api-keys', id]],
[/^google-(.+)$/, (_, id) => ['users', 'google', id]],
[/^user-secrets-(.+)$/, (_, id) => ['users', 'secrets', id]],
[/^provider-(.+)-(.+)$/, (_, uid, prov) => ['users', 'provider', uid, prov]],
[/^provider-configs-([0-9a-f-]{36})-(.+)$/, (_, uid, rest) => ['users', 'provider-configs', uid, rest]],
[/^type-(.+)$/, (_, id) => ['types', id]],
[/^types-(.+)$/, (_, rest) => ['types', rest]],
[/^i18n-(.+)$/, (_, rest) => ['i18n', rest]],
// resourceType is first segment (no hyphens); resourceId may contain hyphens (e.g. vfs mount)
[/^acl-([^-]+)-(.+)$/, (_, type, id) => ['acl', type, id]],
[/^layout-(.+)$/, (_, id) => ['layouts', id]],
[/^layouts-(.+)$/, (_, rest) => ['layouts', rest]],
[/^contacts-(.+)$/, (_, rest) => ['contacts', rest]],
[/^contact-(.+)$/, (_, id) => ['contacts', id]],
[/^categories-(.+)$/, (_, rest) => ['categories', rest]],
[/^categories$/, () => ['categories']],
];
export const parseQueryKey = (key: string): string[] => {
for (const [pattern, builder] of KEY_PREFIXES) {
const match = key.match(pattern);
if (match) return builder(...match);
}
return [key]; // fallback: wrap as single-element array
};
export const fetchWithDeduplication = async <T>(
key: string,
fetcher: () => Promise<T>,
timeout: number = 25000,
storage: CacheStorageType = 'local'
): Promise<T> => {
const queryKey = parseQueryKey(key);
return queryClient.fetchQuery({
queryKey,
queryFn: fetcher,
staleTime: timeout > 1 ? timeout : 1000 * 30,
});
};
export const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
/** Join `serverUrl` with a path, or pass through absolute `http(s)://` URLs unchanged. */
export function resolveApiUrl(pathOrUrl: string): string {
if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) return pathOrUrl;
const base = String(serverUrl).replace(/\/$/, '');
const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
return `${base}${path}`;
}
export const getAuthToken = async (): Promise<string | undefined> => {
const user = await userManager.getUser();
return user?.access_token ?? undefined;
};
/** Helper function to get authorization headers */
export const getAuthHeaders = async (): Promise<HeadersInit> => {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
};
async function apiResponseJson<T>(response: Response, endpoint: string): Promise<T> {
if (!response.ok) {
let msg = response.statusText;
const ct = response.headers.get('content-type');
if (ct?.includes('application/json')) {
try {
const body = await response.json();
if (body && typeof body.error === 'string') msg = body.error;
} catch {
/* ignore */
}
}
throw new Error(`API Error on ${endpoint}: ${msg}`);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('text/html')) {
throw new Error(
`API Error on ${endpoint}: HTTP ${response.status} returned text/html instead of JSON — SPA fallback or API upstream unavailable`,
);
}
if (contentType?.includes('application/json')) {
return response.json();
}
return response.text() as unknown as T;
}
/** True when the edge/proxy likely returned the SPA shell or a transient upstream error instead of the API. */
function isTransientApiFailure(response: Response): boolean {
if ([502, 503, 504].includes(response.status)) return true;
if (!response.ok) return false;
const ct = response.headers.get('content-type') || '';
return ct.includes('text/html');
}
const API_CLIENT_MAX_RETRIES = 2;
const API_CLIENT_RETRY_BASE_MS = 350;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function isLikelyNetworkError(err: Error): boolean {
const m = err.message;
return m.includes('fetch') || m.includes('network') || m.includes('Failed to fetch');
}
/**
* Retries when the edge returns the SPA index.html (200 + text/html), 502/503/504,
* or a thrown fetch — common during Node restarts or mis-timed proxy static fallback.
*/
async function fetchWithApiRetries(url: string, init: RequestInit, labelForLog: string): Promise<Response> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt <= API_CLIENT_MAX_RETRIES; attempt++) {
try {
const response = await fetch(url, init);
if (isTransientApiFailure(response) && attempt < API_CLIENT_MAX_RETRIES) {
const ct = response.headers.get('content-type') || '';
console.warn(
`[apiClient] transient (${response.status}${ct ? ` ${ct.split(';')[0]}` : ''}) on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`,
);
await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1));
continue;
}
return response;
} catch (e) {
lastErr = e instanceof Error ? e : new Error(String(e));
if (isLikelyNetworkError(lastErr) && attempt < API_CLIENT_MAX_RETRIES) {
console.warn(`[apiClient] network error on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`);
await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1));
continue;
}
throw lastErr;
}
}
throw lastErr ?? new Error(`fetch failed after retries: ${labelForLog}`);
}
export async function apiClient<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = await getAuthHeaders();
const url = resolveApiUrl(endpoint);
const response = await fetchWithApiRetries(url, {
...options,
headers: { ...headers, ...options.headers },
}, endpoint);
return apiResponseJson<T>(response, endpoint);
}
export async function apiClientOr404<T>(endpoint: string, options: RequestInit = {}): Promise<T | null> {
const headers = await getAuthHeaders();
const url = resolveApiUrl(endpoint);
const response = await fetchWithApiRetries(url, {
...options,
headers: { ...headers, ...options.headers },
}, endpoint);
if (response.status === 404) return null;
return apiResponseJson<T>(response, endpoint);
}
export const invalidateCache = (key: string) => {
const queryKey = parseQueryKey(key);
queryClient.invalidateQueries({ queryKey });
};
export const invalidateServerCache = async (types: string[]) => {
// Explicit cache invalidation is handled by the server on mutation
// No need to call /api/cache/invalidate manually
console.debug('invalidateServerCache: Skipped manual invalidation for', types);
};
export const checkLikeStatus = async (userId: string, pictureId: string) => {
return fetchWithDeduplication(`like-${userId}-${pictureId}`, async () => {
try {
const data = await apiClient<{ liked: boolean }>(`/api/pictures/${pictureId}/like-status?userId=${userId}`);
return data.liked;
} catch {
return false;
}
});
};