import type { ListResponse } from "$lib/types"; /** * UI list page size (explicit `limit=` on fetches). * API default when limit is omitted remains 50 (pagination.go); keep requests explicit. */ export const DEFAULT_PAGE_SIZE = 25; export const MAX_PAGE_LIMIT = 200; /** Cap for filter/option dropdowns (API maxPageLimit). */ export const OPTION_LIST_LIMIT = 200; /** Cap for category/tree loads (API maxTreePageLimit). */ export const TREE_LIST_LIMIT = 2000; /** Dashboard / checklist recent jobs strip - avoid unbounded job list payloads. */ export const RECENT_JOBS_LIMIT = 10; export const SEARCH_DEBOUNCE_MS = 300; export function clampLimit(limit: number, max = MAX_PAGE_LIMIT): number { if (!Number.isFinite(limit) || limit <= 0) return DEFAULT_PAGE_SIZE; return Math.min(Math.floor(limit), max); } /** True for aborted fetch/AbortController errors (DOMException or Error). */ export function isAbortError(err: unknown): boolean { return ( (err instanceof DOMException && err.name === "AbortError") || (err instanceof Error && err.name === "AbortError") ); } /** Run async work over items with a fixed concurrency cap; preserves result order. */ export async function mapPool< T, R >(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise): Promise { const n = items.length; if (n === 0) return []; const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, n)); const results = new Array(n); let next = 0; async function worker() { while (true) { const i = next++; if (i >= n) return; results[i] = await fn(items[i]!, i); } } await Promise.all(Array.from({ length: limit }, () => worker())); return results; } /** 1-based page -> offset for limit/offset APIs. */ export function pageOffset(page: number, pageSize = DEFAULT_PAGE_SIZE): number { const p = Math.max(1, Math.floor(Number(page)) || 1); const size = clampLimit(pageSize); return (p - 1) * size; } /** * Align with apps/api/internal/catalog MaxOffsetWithoutCursor. * Product list rejects OFFSET above this without cursor/after_id. */ export const MAX_OFFSET_WITHOUT_CURSOR = 5000; /** Last 1-based page still reachable via OFFSET alone. */ export function maxOffsetSafePage(pageSize = DEFAULT_PAGE_SIZE): number { const size = clampLimit(pageSize); return Math.floor(MAX_OFFSET_WITHOUT_CURSOR / size) + 1; } /** * Clamp a requested page so OFFSET stays within the API keyset gate. * Deep URL pages fall back to page 1 (cursor-first — do not deep-OFFSET). */ export function clampPageForOffset( page: number, pageSize = DEFAULT_PAGE_SIZE ): number { const p = Math.max(1, Math.floor(Number(page)) || 1); if (pageOffset(p, pageSize) <= MAX_OFFSET_WITHOUT_CURSOR) return p; return 1; } /** Safe OFFSET for a page, or null when the page requires a keyset cursor. */ export function offsetOrNullForPage( page: number, pageSize = DEFAULT_PAGE_SIZE ): number | null { const offset = pageOffset(page, pageSize); if (offset > MAX_OFFSET_WITHOUT_CURSOR) return null; return offset; } function readTrimmedString(payload: unknown, key: string): string | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const value = (payload as Record)[key]; if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } /** Opaque keyset bookmark from product list responses (`next_cursor`). */ export function unwrapNextCursor(payload: unknown): string | null { return readTrimmedString(payload, "next_cursor"); } /** Tie-break id from product list responses (`next_after_id`). */ export function unwrapNextAfterId(payload: unknown): string | null { return readTrimmedString(payload, "next_after_id"); } export type SequentialNextQuery = { cursor: string } | { after_id: string }; /** Bookmark that loads a page: `null` = first page (no cursor), else keyset query. */ export type PageBookmark = SequentialNextQuery | null; /** * @deprecated Products UI always uses keyset (preferSequentialPagination). * Kept for older callers that multiply by page size. */ export const OFFSET_SAFE_MAX_PAGES = 0; /** * Products list: always prefer sequential keyset (cursor/after_id) over OFFSET. * API rejects offset > MaxOffsetWithoutCursor (5000) without a cursor. */ export function preferSequentialPagination( _total?: number | null, _pageSize = DEFAULT_PAGE_SIZE ): boolean { return true; } /** Params for sequential "next" when the API returned a keyset bookmark. * Prefers opaque `cursor` over `after_id`. Null -> caller should use offset. */ export function sequentialNextQuery( nextCursor?: string | null, nextAfterId?: string | null ): SequentialNextQuery | null { const cursor = typeof nextCursor === "string" ? nextCursor.trim() : ""; if (cursor) return { cursor }; const afterId = typeof nextAfterId === "string" ? nextAfterId.trim() : ""; if (afterId) return { after_id: afterId }; return null; } /** Fresh stack for page 1 (no cursor). */ export function resetPageBookmarks(): PageBookmark[] { return [null]; } /** Bookmark that loads 1-based `page`, or `undefined` if that page was not visited. */ export function bookmarkForPage( stack: readonly PageBookmark[], page: number ): PageBookmark | undefined { const p = Math.max(1, Math.floor(Number(page)) || 1); if (p === 1) return null; const idx = p - 1; if (idx < 0 || idx >= stack.length) return undefined; return stack[idx] ?? undefined; } /** Record the bookmark used to load 1-based `page` (truncates any deeper entries). */ export function recordPageBookmark( stack: readonly PageBookmark[], page: number, bookmark: PageBookmark ): PageBookmark[] { const p = Math.max(1, Math.floor(Number(page)) || 1); const out = stack.slice(0, p - 1); while (out.length < p - 1) out.push(null); out.push(bookmark); return out; } export function debounce( fn: (...args: Args) => void, ms: number ): ((...args: Args) => void) & { cancel: () => void } { let timer: ReturnType | null = null; const wrapped = ((...args: Args) => { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...args); }, ms); }) as ((...args: Args) => void) & { cancel: () => void }; wrapped.cancel = () => { if (timer) clearTimeout(timer); timer = null; }; return wrapped; } const NAMED_LIST_KEYS = [ "products", "categories", "attributes", "category_attributes", "api_keys", "files", "feeds", "export_feeds", "variables", "jobs", "members", "team", "items", "data", "fields", "groups", "campaigns", "templates", "orders", "reviews", "tickets", "notifications", "messages" ] as const; export function unwrapList(payload: ListResponse | Record | T[]): T[] { if (Array.isArray(payload)) return payload; if (!payload || typeof payload !== "object") return []; const record = payload as Record; for (const key of NAMED_LIST_KEYS) { const value = record[key]; if (Array.isArray(value)) return value as T[]; } return []; } export function unwrapTotal(payload: unknown): number | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const total = (payload as Record).total; return typeof total === "number" ? total : null; }