import { getResolveSpecClient } from '@warkypublic/resolvespec-js'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { AdapterConfig, AdapterRef } from './types'; import { useGriddyStore } from '../core/GriddyStore'; import { applyCursor, buildOptions } from './mapOptions'; export const ResolveSpecAdapter = forwardRef( function ResolveSpecAdapter(props, ref) { const { autoFetch = true, baseUrl, columnMap, computedColumns, cursorField = 'id', customOperators, debounceMs = 300, defaultOptions, entity, mode = 'cursor', pageSize = 25, preload, schema, token, } = props; const sorting = useGriddyStore((s) => s.sorting ?? []); const columnFilters = useGriddyStore((s) => s.columnFilters ?? []); const pagination = useGriddyStore((s) => s.pagination); const paginationState = useGriddyStore((s) => s.paginationState); const setData = useGriddyStore((s) => s.setData); const appendData = useGriddyStore((s) => s.appendData); const setDataCount = useGriddyStore((s) => s.setDataCount); const setIsLoading = useGriddyStore((s) => s.setIsLoading); const setError = useGriddyStore((s) => s.setError); const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll); const clientRef = useRef(getResolveSpecClient({ baseUrl, token })); const debounceRef = useRef>(null); const mountedRef = useRef(true); // Infinite scroll state (cursor mode) const cursorRef = useRef(null); const hasMoreRef = useRef(true); const [cursorLoading, setCursorLoading] = useState(false); useEffect(() => { clientRef.current = getResolveSpecClient({ baseUrl, token }); }, [baseUrl, token]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // ─── Offset mode fetch (original behavior) ─── const fetchDataOffset = useCallback(async () => { if (!mountedRef.current) return; setIsLoading(true); setError(null); try { // Fall back to config when store hasn't synced yet (initial render) const effectivePagination = paginationState ?? (pagination?.enabled ? { pageIndex: 0, pageSize: pagination.pageSize } : undefined); const options = buildOptions( sorting, columnFilters, effectivePagination, columnMap, defaultOptions ); if (preload) options.preload = preload; if (computedColumns) options.computedColumns = computedColumns; if (customOperators) options.customOperators = customOperators; const response = await clientRef.current.read(schema, entity, undefined, options); if (!mountedRef.current) return; if (response.success) { const rows = Array.isArray(response.data) ? response.data : [response.data]; setData(rows); if (response.metadata?.total != null) { setDataCount(response.metadata.total); } } else if (response.error) { setError(new Error(response.error.message ?? 'Request failed')); } } catch (err) { if (mountedRef.current) { setError(err instanceof Error ? err : new Error(String(err))); } } finally { if (mountedRef.current) { setIsLoading(false); } } }, [ sorting, columnFilters, pagination, paginationState, columnMap, defaultOptions, preload, computedColumns, customOperators, schema, entity, setData, setDataCount, setIsLoading, setError, ]); // ─── Cursor mode fetch (uses cursor_forward only) ─── const fetchCursorPage = useCallback( async (cursor: null | string, isAppend: boolean) => { if (!mountedRef.current) return; if (isAppend) { setCursorLoading(true); } else { setIsLoading(true); } setError(null); try { const options = buildOptions( sorting, columnFilters, undefined, columnMap, defaultOptions ); if (preload) options.preload = preload; if (computedColumns) options.computedColumns = computedColumns; if (customOperators) options.customOperators = customOperators; const cursorOptions = applyCursor(options, pageSize, cursor); const response = await clientRef.current.read(schema, entity, undefined, cursorOptions); if (!mountedRef.current) return; if (response.success) { const rows = Array.isArray(response.data) ? response.data : [response.data]; if (isAppend) { appendData(rows); } else { setData(rows); } if (response.metadata?.total) { setDataCount(response.metadata.total); } else if (response.metadata?.count) { setDataCount(response.metadata.count); } // Extract cursor from last row if (rows.length > 0) { const lastRow = rows[rows.length - 1]; if (lastRow?.[cursorField] == null) { hasMoreRef.current = false; setError(new Error( `Cursor field "${cursorField}" not found in response data. ` + `Set cursorField to match your data's primary key, or use mode="offset".` )); return; } cursorRef.current = String(lastRow[cursorField]); } hasMoreRef.current = rows.length >= pageSize; } else if (response.error) { setError(new Error(response.error.message ?? 'Request failed')); } } catch (err) { if (mountedRef.current) { setError(err instanceof Error ? err : new Error(String(err))); } } finally { if (mountedRef.current) { if (isAppend) { setCursorLoading(false); } else { setIsLoading(false); } } } }, [ sorting, columnFilters, columnMap, defaultOptions, preload, computedColumns, customOperators, schema, entity, pageSize, cursorField, setData, appendData, setDataCount, setIsLoading, setError, ] ); const fetchNextPage = useCallback(() => { if (!hasMoreRef.current || cursorLoading) return; return fetchCursorPage(cursorRef.current, true); }, [cursorLoading, fetchCursorPage]); const resetAndFetch = useCallback(async () => { cursorRef.current = null; hasMoreRef.current = true; await fetchCursorPage(null, false); }, [fetchCursorPage]); // ─── Unified fetch dispatch ─── const fetchData = mode === 'cursor' ? resetAndFetch : fetchDataOffset; // ─── Infinite scroll config sync (cursor mode only) ─── useEffect(() => { if (mode !== 'cursor' || pagination?.enabled) { setInfiniteScroll(undefined); return; } setInfiniteScroll({ enabled: true, hasMore: hasMoreRef.current, isLoading: cursorLoading, onLoadMore: fetchNextPage, threshold: 10, }); }, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]); useEffect(() => { return () => { setInfiniteScroll(undefined); }; }, [setInfiniteScroll]); useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]); const initialFetchDone = useRef(false); useEffect(() => { if (autoFetch && !initialFetchDone.current) { initialFetchDone.current = true; fetchData(); } }, [autoFetch, fetchData]); const prevDepsRef = useRef(null); useEffect(() => { const depsKey = mode === 'cursor' ? JSON.stringify({ columnFilters, sorting }) : JSON.stringify({ columnFilters, paginationState, sorting }); if (prevDepsRef.current === null) { prevDepsRef.current = depsKey; return; } if (prevDepsRef.current === depsKey) return; prevDepsRef.current = depsKey; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(fetchData, debounceMs); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [sorting, columnFilters, paginationState, debounceMs, fetchData, mode]); return null; } );