UNPKG

supastash

Version:

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

139 lines (138 loc) 5.41 kB
import { getSupastashConfig } from "../../../core/config"; import { isOnline } from "../../../utils/connection"; import { log, logWarn } from "../../../utils/logs"; import { getQueryStatusFromDb } from "../../../utils/sync/queryStatus"; import { supastashEventBus } from "../../events/eventBus"; import { querySupabase } from "../remoteQuery/supabaseQuery"; let batchQueue = []; let isProcessing = false; let batchTimer = null; const BATCH_DELAY = 10; const MAX_RETRIES = 3; const MAX_OFFLINE_RETRIES = 5; const retryCount = new Map(); const calledOfflineRetries = new Map(); const successfulCalls = new Set(); const seenFailureLog = new Set(); function isDuplicateKeyError(error) { return (!!error && ((error.code && error.code === "23505") || (typeof error.message === "string" && error.message.toLowerCase().includes("duplicate key")))); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function generateOpKey(state) { return `${state.table}_${state.method}_${state.id}_${JSON.stringify(state.payload)}`; } export function queueRemoteCall(state) { return new Promise((resolve, reject) => { const opKey = generateOpKey(state); if (successfulCalls.has(opKey)) { resolve(true); return; } batchQueue.push({ state, opKey, resolve, reject }); scheduleBatch(); }); } function scheduleBatch() { if (batchTimer) return; batchTimer = setTimeout(() => { processBatch(); batchTimer = null; }, BATCH_DELAY); } async function executeSupabaseCall(state) { const config = getSupastashConfig(); const payload = state.payload; if (!Array.isArray(payload)) { return querySupabase(state, true); } const batchSize = config.supabaseBatchSize ?? 100; for (let i = 0; i < payload.length; i += batchSize) { const chunk = payload.slice(i, i + batchSize); const result = await querySupabase({ ...state, payload: chunk, }, true); if (result.error) { return result; } } return { error: null }; } async function processBatch() { if (isProcessing || batchQueue.length === 0) return; isProcessing = true; const batch = [...batchQueue]; batchQueue = []; for (const call of batch) { const { state, opKey, resolve, reject } = call; if (successfulCalls.has(opKey)) { resolve(true); continue; } try { while ((retryCount.get(opKey) ?? 0) <= MAX_RETRIES) { const isConnected = await isOnline(); if (!isConnected) { const offlineRetries = (calledOfflineRetries.get(opKey) || 0) + 1; calledOfflineRetries.set(opKey, offlineRetries); if (offlineRetries > MAX_OFFLINE_RETRIES) { if (!seenFailureLog.has(opKey)) { seenFailureLog.add(opKey); log(`[Supastash] Offline — persisted locally, will retry later: ${opKey}`); } await getQueryStatusFromDb(state.table); supastashEventBus.emit("updateSyncStatus"); resolve(false); break; } await delay(1000); continue; } calledOfflineRetries.delete(opKey); const { error } = await executeSupabaseCall(state); if (!error) { successfulCalls.add(opKey); retryCount.delete(opKey); log(`[Supastash] Synced item on ${state.table} with ${state.method} to supabase`); resolve(true); break; } else { const currentRetries = retryCount.get(opKey) ?? 0; retryCount.set(opKey, currentRetries + 1); if (isDuplicateKeyError(error)) { if (!seenFailureLog.has(opKey)) { seenFailureLog.add(opKey); logWarn(`[Supastash] Duplicate key on ${state.table} (op=${state.method}) — seems already synced; will retry on next full sync: id=${state.id ?? "-"}`); } resolve(true); break; } if (currentRetries >= MAX_RETRIES) { if (!seenFailureLog.has(opKey)) { seenFailureLog.add(opKey); logWarn(`[Supastash] Gave up on ${state.table} with ${state.method} after ${MAX_RETRIES} retries — will retry on next sync \nError message: ${error.message}`); } resolve(false); break; } await delay(1000 * (currentRetries + 1)); } } } catch (err) { if (!seenFailureLog.has(opKey)) { seenFailureLog.add(opKey); logWarn(`[Supastash] Unexpected error processing ${opKey}${String(err.message ?? err)}`); } } } isProcessing = false; }