import type { D3roSupabaseClient, HistoryEntry } from '@d3ro/api-client' export type HistoryListFilter = 'all' | 'favorites' export interface HistoryCursor { createdAt: string id: string } export interface HistoryPageOptions { userId: string filter: HistoryListFilter search: string pageSize: number cursor: HistoryCursor | null } export interface HistoryPageResult { entries: HistoryEntry[] nextCursor: HistoryCursor | null } export type HistoryClientErrorCode = 'auth' | 'conflict' | 'not-found' | 'validation' | 'network' | 'server' export class HistoryClientError extends Error { constructor( public readonly code: HistoryClientErrorCode, message: string ) { super(message) this.name = 'HistoryClientError' } } export interface HistoryUpdate { title?: string | null original_text?: string polished_text?: string | null is_favorite?: boolean } const MAX_PAGE_SIZE = 50 const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i function requireUuid(value: string, code: 'auth' | 'validation', message: string): void { if (!UUID_PATTERN.test(value)) throw new HistoryClientError(code, message) } function mapError(error: unknown): HistoryClientError { if (error instanceof HistoryClientError) return error const candidate = error as { code?: unknown; message?: unknown } const databaseCode = typeof candidate?.code === 'string' ? candidate.code : '' const message = typeof candidate?.message === 'string' ? candidate.message : 'History request failed' const normalized = message.toLowerCase() if ( error instanceof TypeError || normalized.includes('failed to fetch') || normalized.includes('network request failed') || normalized.includes('networkerror') ) { return new HistoryClientError('network', message) } if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) { return new HistoryClientError('auth', message) } return new HistoryClientError('server', message) } export function sanitizeHistorySearch(value: string): string { return value .trim() .slice(0, 100) .replace(/[%,()._'"\\]/g, ' ') .replace(/\s+/g, ' ') .trim() } export async function listHistoryPage( client: D3roSupabaseClient, options: HistoryPageOptions ): Promise { requireUuid(options.userId, 'auth', 'A valid authenticated user is required') const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE)) try { let query = client .from('history') .select('*') .eq('user_id', options.userId) .order('created_at', { ascending: false }) .order('id', { ascending: false }) .limit(pageSize + 1) if (options.filter === 'favorites') query = query.eq('is_favorite', true) if (options.cursor !== null) { query = query.or( `created_at.lt.${options.cursor.createdAt},and(created_at.eq.${options.cursor.createdAt},id.lt.${options.cursor.id})` ) } const search = sanitizeHistorySearch(options.search) if (search.length > 0) { const pattern = `%${search}%` query = query.or( `title.ilike.${pattern},original_text.ilike.${pattern},polished_text.ilike.${pattern},summary_text.ilike.${pattern}` ) } const { data, error } = await query if (error) throw error const rows = data ?? [] const hasMore = rows.length > pageSize const entries = hasMore ? rows.slice(0, pageSize) : rows const last = entries.at(-1) return { entries, nextCursor: hasMore && last ? { createdAt: last.created_at, id: last.id } : null } } catch (error) { throw mapError(error) } } export async function getHistoryEntry( client: D3roSupabaseClient, userId: string, entryId: string ): Promise { requireUuid(userId, 'auth', 'A valid authenticated user is required') requireUuid(entryId, 'validation', 'A valid history id is required') try { const { data, error } = await client .from('history') .select('*') .eq('user_id', userId) .eq('id', entryId) .maybeSingle() if (error) throw error if (!data) throw new HistoryClientError('not-found', 'History entry was not found') return data } catch (error) { throw mapError(error) } } export async function updateHistoryEntryRevisionSafe( client: D3roSupabaseClient, userId: string, entryId: string, expectedRevision: number, patch: HistoryUpdate ): Promise { requireUuid(userId, 'auth', 'A valid authenticated user is required') requireUuid(entryId, 'validation', 'A valid history id is required') if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { throw new HistoryClientError('validation', 'Expected revision is invalid') } if (Object.keys(patch).length === 0) { throw new HistoryClientError('validation', 'At least one field is required') } try { const { data, error } = await client .from('history') .update({ ...patch, revision: expectedRevision + 1 }) .eq('user_id', userId) .eq('id', entryId) .eq('revision', expectedRevision) .select('*') .maybeSingle() if (error) throw error if (!data) throw new HistoryClientError('conflict', 'History revision changed') return data } catch (error) { throw mapError(error) } } export async function deleteHistoryEntryRevisionSafe( client: D3roSupabaseClient, userId: string, entryId: string, expectedRevision: number ): Promise { requireUuid(userId, 'auth', 'A valid authenticated user is required') requireUuid(entryId, 'validation', 'A valid history id is required') if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { throw new HistoryClientError('validation', 'Expected revision is invalid') } try { const { data, error } = await client .from('history') .delete() .eq('user_id', userId) .eq('id', entryId) .eq('revision', expectedRevision) .select('id') .maybeSingle() if (error) throw error if (!data) throw new HistoryClientError('conflict', 'History revision changed') } catch (error) { throw mapError(error) } }