supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
118 lines (117 loc) • 4.32 kB
JavaScript
// NOT READY FOR USE YET
import { useCallback, useMemo, useRef, useState } from "react";
import { getSupastashDb } from "../../db/dbInitializer";
import { buildFilters, sanitizeOrderBy, sanitizeTableName, } from "../../utils/fetchData/liteHelpers";
import { logError } from "../../utils/logs";
import { isTrulyNullish } from "../../utils/serializer";
/**
* useSupastashLiteQuery
*
* A lightweight alternative to `useSupastashData` — purpose-built for local-first apps
* that need precise control over data loading without the overhead of Supabase Realtime
* or global caching mechanisms.
*
* 🧩 Key Differences from `useSupastashData`:
* - No realtime listeners or sync triggers
* - No global cache — only the queried data is held in memory
* - Manual refresh and pagination: nothing is loaded unless explicitly requested
*
* 🔍 Purpose:
* Ideal for views where memory usage must be minimized and only essential data is required.
* Perfect for infinite scrolls, segmented screens, or "lite mode" interfaces.
*
* ✅ Features:
* - Offset-based pagination with `loadMore`
* - SQL-compliant filtering and ordering
* - Implicit soft delete handling (`deleted_at IS NULL`)
* - Event-driven refresh support (`liteQueryRefresh:<table>`)
*
* ⚙️ Defaults:
* - pageSize: 50
* - orderBy: "created_at"
* - orderDesc: true
*
* ⚠️ Requirements:
* - Table must have a string-based `id` column
*
* @template R - The row type for the queried table
* @param {string} table - SQLite table name
* @param {LiteQueryOptions} options - Optional SQL filters, order, and page size
*
*
* @example
* const { data, loadMore, refresh } = useSupastashLiteQuery("orders", {
* sqlFilter: [{ column: "shop_id", operator: "eq", value: activeShopId }],
* });
*
* <FlatList
* data={data}
* keyExtractor={(item) => item.id}
* renderItem={({ item }) => <OrderCard item={item} />}
* onRefresh={refresh}
* refreshing={isLoading}
* ListEmptyComponent={<NoOrders />}
* ListFooterComponent={hasMore && isLoading ? <LoadMoreButton loadMore={loadMore} /> : null}
* />
// */
export function useSupastashLiteQuery(table, options = {}) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const dbRef = useRef(null);
const loadMore = useCallback(async (isRefresh = false) => {
if (loading)
return;
if (!dbRef.current) {
dbRef.current = await getSupastashDb();
}
const db = dbRef.current;
setLoading(true);
try {
const amount = (page + 1) * (options.pageSize ?? 50);
const sanitizedTable = sanitizeTableName(table);
const sanitizedOrderBy = sanitizeOrderBy(options.orderBy ?? "created_at");
const limit = options.pageSize ?? 50;
const orderDirection = options.orderDesc === false ? "ASC" : "DESC";
const filters = await buildFilters(options.sqlFilter ?? [], sanitizedTable, true);
const query = `
SELECT * FROM ${sanitizedTable}
WHERE deleted_at IS NULL${filters}
ORDER BY ${sanitizedOrderBy} ${orderDirection}
LIMIT ${amount};
`;
const rows = await db.getAllAsync(query);
if (rows && rows.length > 0) {
setData(rows);
setPage((prev) => (isRefresh ? prev : prev + 1));
setHasMore(rows.length === limit);
}
}
catch (error) {
logError(error);
}
finally {
setLoading(false);
}
}, [
table,
options.pageSize,
options.orderBy,
options.orderDesc,
options.sqlFilter,
]);
const reset = useCallback(() => {
setPage(0);
setHasMore(true);
setLoading(false);
setData([]);
loadMore();
}, [loadMore]);
const isAnyNullish = useMemo(() => {
if (!options.sqlFilter)
return false;
return options.sqlFilter.some((filter) => isTrulyNullish(filter.value) && filter.operator !== "is");
}, [options.sqlFilter]);
return { data, loadMore, loading, hasMore, reset };
}