const INDEX_COLUMNS = [ "anilist_id", "mangadex_id", "latest_en_chapter_text", "latest_en_chapter_number", "latest_en_volume_text", "volume_chapter_boundaries", "latest_en_chapter_id", "latest_chapter_updated_at", "last_seen_at", "last_indexed_at", "is_releasing", "created_at", "updated_at", ].join(","); const chunk = (items, size) => { const chunks = []; for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); return chunks; }; const supabaseUrl = (env, path) => `${String(env.SUPABASE_URL || "").replace(/\/$/, "")}/rest/v1/${path}`; const supabaseHeaders = (env, headers = {}) => ({ apikey: env.SUPABASE_SERVICE_ROLE_KEY, Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`, ...headers, }); const supabaseRequest = async ( env, path, { method = "GET", query = {}, body, headers = {}, prefer = [], returnNullOn404 = false, } = {}, ) => { const url = new URL(supabaseUrl(env, path)); for (const [key, value] of Object.entries(query)) if (value !== undefined && value !== null) url.searchParams.set(key, value); const response = await fetch(url, { method, headers: supabaseHeaders(env, { ...(body ? { "Content-Type": "application/json" } : {}), ...(prefer.length ? { Prefer: prefer.join(",") } : {}), ...headers, }), body: body ? JSON.stringify(body) : undefined, }); if (returnNullOn404 && response.status === 404) return null; if (!response.ok) { const message = await response.text(); throw new Error(`Supabase request failed (${response.status}): ${message}`); } if (response.status === 204) return null; const text = await response.text(); if (!text.length) return null; return JSON.parse(text); }; const buildInFilter = (values) => `in.(${values.join(",")})`; export const hasSupabaseConfig = (env) => Boolean(env.SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY); export const getMangadexRowsByAniListIds = async (env, anilistIds) => { const rows = []; for (const ids of chunk([...new Set(anilistIds)], 100)) rows.push( ...((await supabaseRequest(env, "mangadex_manga_index", { query: { select: INDEX_COLUMNS, anilist_id: buildInFilter(ids), }, })) || []), ); return rows; }; export const getMangadexRowsByMangadexIds = async (env, mangadexIds) => { const rows = []; for (const ids of chunk([...new Set(mangadexIds)], 100)) rows.push( ...((await supabaseRequest(env, "mangadex_manga_index", { query: { select: INDEX_COLUMNS, mangadex_id: buildInFilter(ids), }, })) || []), ); return rows; }; export const getMangadexFailureRowsByAniListIds = async (env, anilistIds) => { const rows = []; for (const ids of chunk([...new Set(anilistIds)], 100)) rows.push( ...((await supabaseRequest(env, "mangadex_resolution_failures", { query: { select: "anilist_id,last_attempted_at,updated_at", anilist_id: buildInFilter(ids), }, returnNullOn404: true, })) || []), ); return rows; }; export const upsertMangadexRows = async (env, rows) => { if (!rows.length) return []; return supabaseRequest(env, "mangadex_manga_index", { method: "POST", query: { on_conflict: "anilist_id", }, body: rows, prefer: ["resolution=merge-duplicates", "return=representation"], }); }; export const touchMangadexRows = async (env, rows) => { if (!rows.length) return []; return upsertMangadexRows( env, rows.map((row) => ({ anilist_id: row.anilist_id, mangadex_id: row.mangadex_id, is_releasing: row.is_releasing, last_seen_at: row.last_seen_at, updated_at: row.updated_at, })), ); }; export const upsertMangadexFailureRows = async (env, anilistIds) => { if (!anilistIds.length) return null; return supabaseRequest(env, "mangadex_resolution_failures", { method: "POST", query: { on_conflict: "anilist_id", }, body: [...new Set(anilistIds)].map((anilistId) => ({ anilist_id: anilistId, last_attempted_at: new Date().toISOString(), updated_at: new Date().toISOString(), })), prefer: ["resolution=merge-duplicates", "return=minimal"], returnNullOn404: true, }); }; export const deleteMangadexFailureRows = async (env, anilistIds) => { if (!anilistIds.length) return null; return supabaseRequest(env, "mangadex_resolution_failures", { method: "DELETE", query: { anilist_id: buildInFilter([...new Set(anilistIds)]), }, prefer: ["return=minimal"], returnNullOn404: true, }); }; export const getSyncState = async (env, name) => { const rows = await supabaseRequest(env, "mangadex_sync_state", { query: { select: "name,cursor_updated_at,updated_at", name: `eq.${name}`, limit: "1", }, }); return rows?.[0] || null; }; export const upsertSyncState = async (env, name, cursorUpdatedAt) => supabaseRequest(env, "mangadex_sync_state", { method: "POST", query: { on_conflict: "name", }, body: [ { name, cursor_updated_at: cursorUpdatedAt, updated_at: new Date().toISOString(), }, ], prefer: ["resolution=merge-duplicates", "return=minimal"], });