supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
232 lines (231 loc) • 7.12 kB
JavaScript
import { useEffect, useRef } from "react";
import { AppState } from "react-native";
import { getSupastashConfig } from "../../core/config";
import { syncCalls } from "../../store/syncCalls";
import { tableFilters } from "../../store/tableFilters";
import { isOnline } from "../../utils/connection";
import log from "../../utils/logs";
import { updateLocalDb } from "../../utils/sync/pullFromRemote/updateLocalDb";
import { pushLocalDataToRemote } from "../../utils/sync/pushLocal/sendUnsyncedToSupabase";
import { pullFromRemote as doPullFromRemote } from "./pullFromRemote";
import { pushLocalData as doPushLocalData } from "./pushLocal";
// -----------------------------
// Module-scoped state & tunables
// -----------------------------
let isSyncing = false;
let isPushing = false;
let isPulling = false;
let lastPullAt = 0;
let lastPushAt = 0;
const MIN_FOREGROUND_GAP = 5000; // ms
// -----------------------------
// Core orchestration
// -----------------------------
/**
* Push then (optionally) pull.
* - Single flight across entire app via module-scoped flags.
* - Both directions gated on connectivity.
* - "force" ignores pull cadence timing.
*/
export async function syncAll(force = false) {
if (isSyncing)
return;
if (!(await isOnline()))
return;
// If in ghost mode, don't sync
const cfg = getSupastashConfig();
if (cfg.supastashMode === "ghost")
return;
isSyncing = true;
const started = Date.now();
try {
// PUSH
if (cfg.syncEngine?.push) {
await pushLocalDataSafe();
}
// PULL
if (cfg.syncEngine?.pull) {
const pullInterval = cfg.pollingInterval?.pull ?? 30000;
const now = Date.now();
const due = force || now - lastPullAt >= pullInterval;
if (due) {
await pullFromRemoteSafe();
lastPullAt = now;
}
}
}
catch (e) {
log("[Supastash] Error in syncAll", {
msg: String(e),
code: e?.code ?? e?.name ?? "UNKNOWN",
});
}
finally {
isSyncing = false;
log(`[Supastash] syncAll finished in ${Date.now() - started}ms`);
}
}
// -----------------------------
// Directional helpers
// -----------------------------
async function pushLocalDataSafe() {
if (isPushing)
return;
if (!(await isOnline()))
return;
const cfg = getSupastashConfig();
if (!cfg.syncEngine?.push)
return;
// If in ghost mode, don't push
if (cfg.supastashMode === "ghost")
return;
isPushing = true;
try {
await doPushLocalData();
lastPushAt = Date.now();
}
catch (e) {
log("[Supastash] push error", {
msg: String(e),
code: e?.code ?? e?.name ?? "UNKNOWN",
});
}
finally {
isPushing = false;
}
}
async function pullFromRemoteSafe() {
if (isPulling)
return;
if (!(await isOnline()))
return;
// If in ghost mode, don't pull
const cfg = getSupastashConfig();
if (cfg.supastashMode === "ghost")
return;
if (!cfg.syncEngine?.pull)
return;
isPulling = true;
try {
await doPullFromRemote();
lastPullAt = Date.now();
}
catch (e) {
log("[Supastash] pull error", {
msg: String(e),
code: e?.code ?? e?.name ?? "UNKNOWN",
});
}
finally {
isPulling = false;
}
}
// -----------------------------
// React hook: timer & lifecycle management
// -----------------------------
/**
* Hook to start/stop the periodic sync engine.
* - Staggers push & pull timers.
* - Debounced foreground trigger.
* - Shares module-level single-flight guards with syncAll().
*/
export function useSyncEngine() {
// Timers & lifecycle
const intervalRefPush = useRef(null);
const intervalRefPull = useRef(null);
const appStateSubRef = useRef(null);
// Debounce foreground re-entry
const lastForeground = useRef(0);
function startSync() {
if (intervalRefPush.current ||
intervalRefPull.current ||
appStateSubRef.current) {
return;
}
// Kick a one-shot global sync on start
void syncAll(true);
const cfg = getSupastashConfig();
const pushEvery = cfg.pollingInterval?.push ?? 30000;
const pullEvery = cfg.pollingInterval?.pull ?? 30000;
// Push ticker
intervalRefPush.current = setInterval(() => {
void pushLocalDataSafe();
}, pushEvery);
// Pull ticker
intervalRefPull.current = setInterval(() => {
void pullFromRemoteSafe();
}, pullEvery + 500);
// Foreground trigger
appStateSubRef.current = AppState.addEventListener("change", (state) => {
if (state !== "active")
return;
const now = Date.now();
if (now - lastForeground.current < MIN_FOREGROUND_GAP)
return;
lastForeground.current = now;
void syncAll(true);
});
}
function stopSync() {
if (intervalRefPush.current) {
clearInterval(intervalRefPush.current);
intervalRefPush.current = null;
}
if (intervalRefPull.current) {
clearInterval(intervalRefPull.current);
intervalRefPull.current = null;
}
appStateSubRef.current?.remove?.();
appStateSubRef.current = null;
}
// Auto-cleanup if the hook lives in a component lifecycle
useEffect(() => stopSync, []);
return { startSync, stopSync };
}
// -----------------------------
// Manual triggers
// -----------------------------
/**
* Manually sync a single table (pull then push for that table).
* - Uses table-specific handlers from syncCalls if provided.
* - Respects configured filters when enabled.
*/
export async function syncTable(table) {
if (!(await isOnline()))
return;
const cfg = getSupastashConfig();
const { useFiltersFromStore = true } = cfg?.syncEngine || {};
const filter = useFiltersFromStore ? tableFilters.get(table) : undefined;
// Pull
if (cfg.syncEngine?.pull) {
try {
await updateLocalDb(table, filter, syncCalls.get(table)?.pull);
}
catch (e) {
log("[Supastash] syncTable pull error", {
table,
msg: String(e),
code: e?.code ?? e?.name ?? "UNKNOWN",
});
}
}
// Push (use table handler if present)
if (cfg.syncEngine?.push) {
try {
await pushLocalDataToRemote(table, syncCalls.get(table)?.push);
}
catch (e) {
log("[Supastash] syncTable push error", {
table,
msg: String(e),
code: e?.code ?? e?.name ?? "UNKNOWN",
});
}
}
}
/**
* Force a global sync pass now (push then pull if due).
*/
export async function syncAllTables() {
await syncAll(true);
}