167 lines
4.3 KiB
TypeScript
167 lines
4.3 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
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<string, unknown>;
|
|
}): void;
|
|
|
|
/** Flush the queue — retry all pending items via the provided executor. */
|
|
flush(
|
|
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
|
|
): Promise<FlushResult>;
|
|
|
|
/** 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<string, unknown>;
|
|
}): 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<string, unknown>) => Promise<void>
|
|
): Promise<FlushResult> {
|
|
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 };
|
|
}
|