store/offlineQueueStore.ts

hook·mobile·6.8 KB · 236 lignes· Voir l'itinéraire
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}`;