store/offlineQueueStore.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
4 exports
QueuedActionTypeQueuedActionuseOfflineQueueStoreuseQueueCount
Code source· typescript· tronqué à 200 lignes sur 236
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getToken } from '@/lib/auth';
import { captureError } from '@/lib/sentry';
export type QueuedActionType =
| 'COMMANDE'
| 'COMMANDE_REPAS'
| 'FAVORI_ADD'
| 'FAVORI_REMOVE'
| 'CART_ADD'
| 'CART_REMOVE'
| 'MESSAGE_SEND'
| 'REVIEW_POST'
| string;
export type QueuedAction = {
id: string;
type: QueuedActionType;
/** Label humain pour debug/UI (ex: "Commande Cils & Or — 12 000 FCFA"). */
label?: string;
payload: unknown;
endpoint: string; // URL absolue
method: 'POST' | 'PATCH' | 'DELETE';
createdAt: number;
attempts: number;
lastError?: string;
};
type OfflineQueueState = {
queue: QueuedAction[];
hydrated: boolean;
/** Indicateur "sync en cours" pour éviter double flush concurrent. */
flushing: boolean;
/** Compteur cumulé des actions abandonnées (≥3 attempts). */
abandoned: number;
hydrate: () => Promise<void>;
enqueue: (
action: Omit<QueuedAction, 'id' | 'createdAt' | 'attempts'>,
) => QueuedAction;
dequeue: (id: string) => void;
incrementAttempts: (id: string, error?: string) => void;
clear: () => void;
/** Tente d'envoyer tous les items. Retry 3 max par item. */
flush: () => Promise<{ ok: number; failed: number; abandoned: number }>;
};
const STORAGE_KEY = 'wari:offline-queue:v1';
const MAX_ATTEMPTS = 3;
function genId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function persist(queue: QueuedAction[]): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
} catch (err) {
// Best-effort : si AsyncStorage casse, on continue en mémoire seule.
captureError(err instanceof Error ? err : new Error('offline-queue persist failed'), {
feature: 'offline-queue',
step: 'persist',
});
}
}
async function readPersisted(): Promise<QueuedAction[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Filtre defensive : on jette les entrées corrompues sans schéma minimal.
return parsed.filter(
(it): it is QueuedAction =>
!!it &&
typeof it === 'object' &&
typeof it.id === 'string' &&
typeof it.endpoint === 'string' &&
typeof it.method === 'string' &&
typeof it.createdAt === 'number',
);
} catch {
return [];
}
}
export const useOfflineQueueStore = create<OfflineQueueState>((set, get) => ({
queue: [],
hydrated: false,
flushing: false,
abandoned: 0,
hydrate: async () => {
if (get().hydrated) return;
const queue = await readPersisted();
set({ queue, hydrated: true });
},
enqueue: (action) => {
const item: QueuedAction = {
...action,
id: genId(),
createdAt: Date.now(),
attempts: 0,
};
const next = [...get().queue, item];
set({ queue: next });
void persist(next);
return item;
},
dequeue: (id) => {
const next = get().queue.filter((q) => q.id !== id);
set({ queue: next });
void persist(next);
},
incrementAttempts: (id, error) => {
const next = get().queue.map((q) =>
q.id === id
? { ...q, attempts: q.attempts + 1, lastError: error ?? q.lastError }
: q,
);
set({ queue: next });
void persist(next);
},
clear: () => {
set({ queue: [], abandoned: 0 });
void persist([]);
},
flush: async () => {
if (get().flushing) return { ok: 0, failed: 0, abandoned: 0 };
set({ flushing: true });
let ok = 0;
let failed = 0;
let abandoned = 0;
try {
// Snapshot au démarrage — toute action enqueuée pendant le flush
// sera traitée au prochain run (évite boucle infinie si re-enqueue).
const snapshot = [...get().queue];
const token = await getToken();
for (const item of snapshot) {
// Pas de token → on garde l'item (l'utilisateur n'est pas connecté
// mais la queue peut survivre à un logout/login intermédiaire).
if (!token) {
failed += 1;
continue;
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let res: Response;
try {
res = await fetch(item.endpoint, {
method: item.method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body:
item.method === 'DELETE' || item.payload === undefined
? undefined
: JSON.stringify(item.payload),
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (res.ok || res.status === 204) {
ok += 1;
get().dequeue(item.id);
continue;
}
// 4xx (sauf 408/429) = payload invalide / état serveur incompatible
// → on abandonne (retry inutile, ça échouera pareil).
// 5xx, 408, 429, 0 → on retry.
const status = res.status;
const isRetryable = status >= 500 || status === 408 || status === 429;
if (!isRetryable) {
abandoned += 1;
set({ abandoned: get().abandoned + 1 });
get().dequeue(item.id);
captureError(
new Error(`offline-queue abandon ${status} ${item.method} ${item.endpoint}`),
{ feature: 'offline-queue', type: item.type, status },
);
continue;
}
// Retryable error
const errText = `HTTP ${status}`;import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getToken } from '@/lib/auth';
import { captureError } from '@/lib/sentry';
export type QueuedActionType =
| 'COMMANDE'
| 'COMMANDE_REPAS'
| 'FAVORI_ADD'
| 'FAVORI_REMOVE'
| 'CART_ADD'
| 'CART_REMOVE'
| 'MESSAGE_SEND'
| 'REVIEW_POST'
| string;
export type QueuedAction = {
id: string;
type: QueuedActionType;
/** Label humain pour debug/UI (ex: "Commande Cils & Or — 12 000 FCFA"). */
label?: string;
payload: unknown;
endpoint: string; // URL absolue
method: 'POST' | 'PATCH' | 'DELETE';
createdAt: number;
attempts: number;
lastError?: string;
};
type OfflineQueueState = {
queue: QueuedAction[];
hydrated: boolean;
/** Indicateur "sync en cours" pour éviter double flush concurrent. */
flushing: boolean;
/** Compteur cumulé des actions abandonnées (≥3 attempts). */
abandoned: number;
hydrate: () => Promise<void>;
enqueue: (
action: Omit<QueuedAction, 'id' | 'createdAt' | 'attempts'>,
) => QueuedAction;
dequeue: (id: string) => void;
incrementAttempts: (id: string, error?: string) => void;
clear: () => void;
/** Tente d'envoyer tous les items. Retry 3 max par item. */
flush: () => Promise<{ ok: number; failed: number; abandoned: number }>;
};
const STORAGE_KEY = 'wari:offline-queue:v1';
const MAX_ATTEMPTS = 3;
function genId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function persist(queue: QueuedAction[]): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
} catch (err) {
// Best-effort : si AsyncStorage casse, on continue en mémoire seule.
captureError(err instanceof Error ? err : new Error('offline-queue persist failed'), {
feature: 'offline-queue',
step: 'persist',
});
}
}
async function readPersisted(): Promise<QueuedAction[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Filtre defensive : on jette les entrées corrompues sans schéma minimal.
return parsed.filter(
(it): it is QueuedAction =>
!!it &&
typeof it === 'object' &&
typeof it.id === 'string' &&
typeof it.endpoint === 'string' &&
typeof it.method === 'string' &&
typeof it.createdAt === 'number',
);
} catch {
return [];
}
}
export const useOfflineQueueStore = create<OfflineQueueState>((set, get) => ({
queue: [],
hydrated: false,
flushing: false,
abandoned: 0,
hydrate: async () => {
if (get().hydrated) return;
const queue = await readPersisted();
set({ queue, hydrated: true });
},
enqueue: (action) => {
const item: QueuedAction = {
...action,
id: genId(),
createdAt: Date.now(),
attempts: 0,
};
const next = [...get().queue, item];
set({ queue: next });
void persist(next);
return item;
},
dequeue: (id) => {
const next = get().queue.filter((q) => q.id !== id);
set({ queue: next });
void persist(next);
},
incrementAttempts: (id, error) => {
const next = get().queue.map((q) =>
q.id === id
? { ...q, attempts: q.attempts + 1, lastError: error ?? q.lastError }
: q,
);
set({ queue: next });
void persist(next);
},
clear: () => {
set({ queue: [], abandoned: 0 });
void persist([]);
},
flush: async () => {
if (get().flushing) return { ok: 0, failed: 0, abandoned: 0 };
set({ flushing: true });
let ok = 0;
let failed = 0;
let abandoned = 0;
try {
// Snapshot au démarrage — toute action enqueuée pendant le flush
// sera traitée au prochain run (évite boucle infinie si re-enqueue).
const snapshot = [...get().queue];
const token = await getToken();
for (const item of snapshot) {
// Pas de token → on garde l'item (l'utilisateur n'est pas connecté
// mais la queue peut survivre à un logout/login intermédiaire).
if (!token) {
failed += 1;
continue;
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let res: Response;
try {
res = await fetch(item.endpoint, {
method: item.method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body:
item.method === 'DELETE' || item.payload === undefined
? undefined
: JSON.stringify(item.payload),
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (res.ok || res.status === 204) {
ok += 1;
get().dequeue(item.id);
continue;
}
// 4xx (sauf 408/429) = payload invalide / état serveur incompatible
// → on abandonne (retry inutile, ça échouera pareil).
// 5xx, 408, 429, 0 → on retry.
const status = res.status;
const isRetryable = status >= 500 || status === 408 || status === 429;
if (!isRetryable) {
abandoned += 1;
set({ abandoned: get().abandoned + 1 });
get().dequeue(item.id);
captureError(
new Error(`offline-queue abandon ${status} ${item.method} ${item.endpoint}`),
{ feature: 'offline-queue', type: item.type, status },
);
continue;
}
// Retryable error
const errText = `HTTP ${status}`;