learning_ai_common_plat/packages/offline-queue/src/index.ts

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 };
}