supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
119 lines (118 loc) • 4.95 kB
JavaScript
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 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");
reject(new Error(`Offline retry limit exceeded for ${opKey}`));
break;
}
await delay(1000);
continue;
}
calledOfflineRetries.delete(opKey);
const { error } = await querySupabase({ ...state }, true);
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 ?? "-"}`);
}
reject(new Error(`Duplicate key (23505) for ${opKey}`));
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}`);
}
reject(new Error(`Max retries exceeded for ${error.message} on ${state.table} with ${state.method}`));
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;
}