/** * Persistent offline retry queue for browser and React Native. * * When an API call fails (offline, timeout, etc.), the operation is * queued in configurable storage and retried on the next flush. * * No Node.js, React, or React Native dependencies. * * @example * ```ts * import { createOfflineQueue } from '@bytelyst/offline-queue'; * * const queue = createOfflineQueue({ * storageKey: 'nomgap-offline-queue', * storage: mmkvStorage, // or localStorage * }); * * // On API failure: * queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } }); * * // On app foreground / auth success: * const result = await queue.flush(async (action, path, payload) => { * await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload); * }); * ``` */ // ── Types ──────────────────────────────────────────────────── export interface QueueStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; } export interface OfflineQueueConfig { /** Storage key for persisting the queue. */ storageKey: string; /** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */ storage: QueueStorage; /** Maximum retry attempts per item. Default: 5. */ maxRetries?: number; /** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */ maxQueueSize?: number; } export interface QueueItem { id: string; action: string; path: string; payload: Record; enqueuedAt: number; retryCount: number; } export interface FlushResult { flushed: number; failed: number; } export interface OfflineQueue { /** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */ enqueue(item: { id: string; action: string; path: string; payload: Record; }): void; /** Flush the queue — retry all pending items via the provided executor. */ flush( executor: (action: string, path: string, payload: Record) => Promise ): Promise; /** Get current queue length. */ length(): number; /** Clear the entire queue. */ clear(): void; } // ── Factory ────────────────────────────────────────────────── export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue { const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config; function loadQueue(): QueueItem[] { try { const raw = storage.getItem(storageKey); if (!raw) return []; return JSON.parse(raw) as QueueItem[]; } catch { return []; } } function saveQueue(queue: QueueItem[]): void { try { storage.setItem(storageKey, JSON.stringify(queue)); } catch { // Storage unavailable } } function enqueue(item: { id: string; action: string; path: string; payload: Record; }): void { const queue = loadQueue(); // Replace existing entry for same entity + action const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action)); // Cap queue size if (filtered.length >= maxQueueSize) { filtered.shift(); } filtered.push({ ...item, enqueuedAt: Date.now(), retryCount: 0, }); saveQueue(filtered); } async function flush( executor: (action: string, path: string, payload: Record) => Promise ): Promise { const queue = loadQueue(); if (queue.length === 0) return { flushed: 0, failed: 0 }; let flushed = 0; const remaining: QueueItem[] = []; for (const item of queue) { try { await executor(item.action, item.path, item.payload); flushed++; } catch { if (item.retryCount + 1 < maxRetries) { remaining.push({ ...item, retryCount: item.retryCount + 1 }); } // else: silently drop — too many retries } } saveQueue(remaining); return { flushed, failed: remaining.length }; } function length(): number { return loadQueue().length; } function clear(): void { saveQueue([]); } return { enqueue, flush, length, clear }; }