0ac4beaaa8
- 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)
113 lines
3.9 KiB
TypeScript
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);
|
|
}
|