UNPKG

supastash

Version:

Offline-first sync engine for Supabase in React Native using SQLite

198 lines (197 loc) 7.1 kB
import { getTableSchema } from "../getTableSchema"; import { logError, logWarn } from "../logs"; import { buildFilterForSql } from "./buildFilter"; export function parseJSONColumns(row) { const parsedRow = { ...row }; const errors = []; for (const key in parsedRow) { const value = parsedRow[key]; if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) { try { parsedRow[key] = JSON.parse(value); } catch (error) { errors.push(`Failed to parse JSON in column ${key}: ${error}`); } } } if (errors.length > 0) { logWarn(`[Supastash] JSON parsing errors: ${errors.join(", ")}`); } return parsedRow; } export function sanitizeTableName(table) { if (!/^[a-zA-Z0-9_-]+$/.test(table)) { throw new Error(`Invalid table name: ${table}`); } return table; } export function sanitizeOrderBy(orderBy) { if (!/^[a-zA-Z0-9_-]+$/.test(orderBy)) { throw new Error(`Invalid order by column: ${orderBy}`); } return orderBy; } function buildFilterKey(table, filters) { return `${table}:${JSON.stringify(filters ?? [])}`; } const filterCache = new Map(); export async function buildFilters(filters, table, noChecks = false) { if (!filters || filters.length === 0) return ""; const filterKey = buildFilterKey(table, filters); let sqlFilter = filterCache.get(filterKey); try { let schemaColumns = []; if (!noChecks) { const schema = await getTableSchema(table); schemaColumns = schema.map((col) => col.trim().toLowerCase()); } const filterStringArray = []; for (const filter of filters) { if (!filter?.column) { logWarn(`[Supastash] Skipping filter with missing column`); continue; } const columnLower = String(filter.column).trim().toLowerCase(); if (!noChecks && !schemaColumns.includes(columnLower)) { logWarn(`[Supastash] Filter column ${String(filter.column)} does not exist in table ${table}`); continue; } try { const filterSql = buildFilterForSql(filter); if (filterSql) { filterStringArray.push(filterSql); } } catch (error) { logError(`[Supastash] Failed to build filter for column ${String(filter.column)}:`, error); } } return filterStringArray.length > 0 ? ` AND ${filterStringArray.join(" AND ")}` : ""; } catch (error) { logError(`[Supastash] Failed to build filters for table ${table}:`, error); return ""; } } // export async function fetchData<R = any>({ // table, // options, // state, // isLoadMore, // isRefresh, // }: FetchOptions<R>): Promise<FetchResult<R> | null> { // const db = await getSupastashDb(); // const sanitizedTable = sanitizeTableName(table); // const sanitizedOrderBy = sanitizeOrderBy(options.orderBy ?? "created_at"); // const limit = options.pageSize ?? 50; // const orderDirection = options.orderDesc === false ? "ASC" : "DESC"; // const enableCursor = options.enableCursor !== false; // try { // const filters = await buildFilters(options.sqlFilter ?? [], sanitizedTable); // const staleTime = options.staleTime ?? 30000; // const isCacheStale = Date.now() - state.lastFetch > staleTime; // if (!isRefresh && !isLoadMore && state.data.length > 0 && !isCacheStale) { // logWarn(`[Supastash] Using cached data for ${table}`); // return null; // } // let query: string; // let params: any[] = []; // if (enableCursor && isLoadMore && state.cursor) { // const operator = orderDirection === "DESC" ? "<" : ">"; // const snapshotClause = state.snapshotTime // ? ` AND created_at <= '${state.snapshotTime}'` // : ""; // query = ` // SELECT * FROM ${sanitizedTable} // WHERE deleted_at IS NULL${filters}${snapshotClause} // AND ${sanitizedOrderBy} ${operator} ? // ORDER BY ${sanitizedOrderBy} ${orderDirection}, id ${orderDirection} // LIMIT ${limit}; // `; // params = [state.cursor]; // } else { // const snapshotClause = // !isRefresh && state.snapshotTime // ? ` AND created_at <= '${state.snapshotTime}'` // : ""; // query = ` // SELECT * FROM ${sanitizedTable} // WHERE deleted_at IS NULL${filters}${snapshotClause} // ORDER BY ${sanitizedOrderBy} ${orderDirection}, id ${orderDirection} // LIMIT ${limit}; // `; // } // const rows = await db.getAllAsync(query, params); // if (!rows || rows.length === 0) { // return { // data: [], // dataMap: new Map(), // groupedBy: undefined, // hasMore: false, // cursor: null, // }; // } // const processed = await processRows<R>( // rows, // options.extraMapKeys, // sanitizedOrderBy // ); // const newCursor = // enableCursor && rows.length > 0 // ? rows[rows.length - 1][sanitizedOrderBy] // : null; // return { // data: processed.parsed, // dataMap: processed.dataMap, // groupedBy: processed.groupedBy, // hasMore: rows.length === limit, // cursor: newCursor, // }; // } catch (error) { // logError(`[Supastash] Failed to fetch data from ${table}:`, error); // throw error; // } // } async function processRows(rows, extraMapKeys, orderByColumn) { const parsed = []; const dataMap = new Map(); const groupedBy = {}; for (const row of rows) { try { const parsedRow = parseJSONColumns(row); parsed.push(parsedRow); dataMap.set(row.id, parsedRow); if (extraMapKeys?.length) { for (const key of extraMapKeys) { if (key === "id") { logWarn(`[Supastash] Key 'id' is redundant - use dataMap.get(id)`); continue; } if (parsedRow[key] == null) { logWarn(`[Supastash] Item ${parsedRow.id} missing ${String(key)}`); continue; } const groupVal = parsedRow[key]; if (!groupedBy[key]) groupedBy[key] = new Map(); if (!groupedBy[key].has(groupVal)) groupedBy[key].set(groupVal, []); groupedBy[key].get(groupVal).push(parsedRow); } } } catch (error) { logError(`[Supastash] Failed to process row:`, error, row); } } return { parsed, dataMap, groupedBy: Object.keys(groupedBy).length > 0 ? groupedBy : undefined, }; }