Files
route-commerce/src/lib/offline/sync.ts
T
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
2026-06-25 23:49:37 -06:00

113 lines
3.9 KiB
TypeScript

// src/lib/offline/sync.ts
import { enqueueAction, getQueuedActions, markInFlight, markSynced, markConflict, markFailed } from "./queue";
export const BACKOFF_SCHEDULE = [1000, 4000, 16000, 60000, 300000] as const;
export const MAX_ATTEMPTS = 5;
export function calculateBackoff(attempt: number): number {
const idx = Math.min(attempt, BACKOFF_SCHEDULE.length - 1);
return BACKOFF_SCHEDULE[idx];
}
export type Dispatcher = (actionName: string, payload: unknown, clientActionId: string) => Promise<
| { ok: true }
| { ok: false; conflict: string }
| { ok: false; error: string }
>;
export interface SyncOptions {
online: boolean;
sleep: (ms: number) => Promise<void>;
now?: () => number;
}
export interface SyncResult {
synced: number;
conflicts: number;
failed: number;
}
// Re-entrancy guard: concurrent `online` events shouldn't kick off parallel
// sync passes that race on the same `markInFlight`/`markSynced` records.
let inFlightSync: Promise<SyncResult> | null = null;
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
if (inFlightSync) return inFlightSync;
inFlightSync = (async () => {
const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
if (!options.online) return result;
const actions = await getQueuedActions();
await Promise.all(
actions.map(async (action) => {
if (action.status === "conflict" || action.status === "failed") {
// Don't auto-retry conflicts/failures; require manual user intervention
return;
}
if (action.attempts >= MAX_ATTEMPTS) {
try {
await markFailed(action.id, "max attempts reached");
} catch (markErr) {
console.warn("[offline-sync] markFailed threw for", action.id, markErr);
}
result.failed += 1;
return;
}
const backoff = calculateBackoff(action.attempts);
if (action.lastAttemptAt) {
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
if (elapsed < backoff) return; // not time yet
}
try {
await markInFlight(action.id);
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
if (dispatchResult.ok) {
await markSynced(action.id);
result.synced += 1;
} else if ("conflict" in dispatchResult) {
await markConflict(action.id, dispatchResult.conflict);
result.conflicts += 1;
} else if ("error" in dispatchResult) {
await markFailed(action.id, dispatchResult.error);
result.failed += 1;
}
} catch (err) {
// A single poisoned record (IDB closed, quota, etc.) must not abort
// the whole sync pass. Swallow the secondary failure with a warn so
// it stays observable in dev.
const message = err instanceof Error ? err.message : String(err);
try {
await markFailed(action.id, message);
} catch (innerErr) {
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
}
result.failed += 1;
}
})
);
return result;
})();
try {
return await inFlightSync;
} finally {
inFlightSync = null;
}
}
/**
* Wire this up to navigator.onLine + 'online' event in the client.
* Returns an unsubscribe function.
*/
export function attachSyncListener(dispatcher: Dispatcher): () => void {
if (typeof window === "undefined") return () => {};
const handler = () => {
void syncPending(dispatcher, { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) });
};
window.addEventListener("online", handler);
// Also fire once on attach if online
if (navigator.onLine) void handler();
return () => window.removeEventListener("online", handler);
}