Concepts clés

Cache

Le cache de Qora permet un accès instantané aux données. Apprenez comment les entrées de cache sont créées, vieillissent et sont collectées par le GC, comment le partage structurel évite les reconstructions inutiles, et comment contrôler chaque comportement.

Fonctionnement du cache

Chaque résultat de requête est stocké en tant que CacheEntry<T> dans un QueryCache en mémoire, indexé par la clé de requête normalisée. Le cache pilote :

  • Réponses instantanées : les données fraîches sont retournées sans appel réseau
  • SWR : les données périmées sont retournées immédiatement pendant que les données fraîches sont récupérées en arrière-plan
  • Mises à jour réactives : chaque CacheEntry contient un broadcast stream ; tout changement d'état atteint immédiatement tous les abonnés actifs
fetchQuery(['users', 1])
  │
  ├─ Cache HIT  (frais)   → retourner les données immédiatement
  ├─ Cache HIT  (périmé)  → retourner les données périmées + revalider en arrière-plan
  └─ Cache MISS           → fetch → stocker → retourner les données

Cycle de vie d'une entrée de cache

Une CacheEntry<T> traverse les étapes suivantes :

Créée (Initial)
  │  fetchQuery / watchQuery déclenché
  ▼
Loading → Success / Failure
  │  staleTime écoulé
  ▼
Périmée (toujours en cache, accessible comme previousData)
  │  dernier abonné se désabonne
  ▼
Timer GC démarré (= gcTime)
  │  timer déclenché, aucun abonné actif
  ▼
Évincée (supprimée du cache)

Un sweep d'éviction en arrière-plan s'exécute également toutes les minutes pour supprimer les entrées inactives expirées.


staleTime vs gcTime

Ces deux durées contrôlent des comportements indépendants :

PropriétéDéfautContrôle
staleTime0 (zéro)Quand déclencher une revalidation en arrière-plan
gcTime5 minutesQuand supprimer une entrée inutilisée du cache
main.dart
QoraOptions(
  staleTime: Duration(minutes: 5),   // données fraîches pendant 5 minutes
  gcTime: Duration(minutes: 10),  // données inutilisées conservées 10 min en cache
)
staleTime: Duration.zero (la valeur par défaut) signifie que les données sont toujours considérées comme périmées. Qora sert quand même les données en cache immédiatement ; il revalide également en arrière-plan à chaque accès.

staleTime en détail

staleTime définit la fenêtre de fraîcheur. Pendant cette fenêtre, les données sont retournées depuis le cache sans aucune activité réseau :

main.dart
// staleTime : 5 minutes

// t = 0:00 → fetch déclenché, Success stocké
// t = 0:01 → fetchQuery → Cache HIT (frais) : pas d'appel réseau
// t = 5:01 → fetchQuery → Cache HIT (périmé) : retourne les données en cache + revalide
// t = 5:01 → requête réseau déclenchée en arrière-plan
// t = 5:02 → Success mis à jour, abonnés au stream notifiés

Définir un staleTime long pour les données qui changent rarement :

main.dart
// Données de configuration : valides pendant 1 heure
options: QoraOptions(staleTime: Duration(hours: 1)),

// Profil utilisateur : valide pendant 10 minutes
options: QoraOptions(staleTime: Duration(minutes: 10)),

// Prix en direct : toujours périmé (défaut)
options: QoraOptions(staleTime: Duration.zero),

gcTime en détail

gcTime détermine combien de temps les données inactives restent en cache après le départ de tous les abonnés. Il préserve les données pour une re-navigation rapide :

main.dart
// L'utilisateur quitte → dernier abonné annule → timer GC démarre
// L'utilisateur revient dans gcTime → entrée toujours en cache → affichage instantané
// L'utilisateur revient après gcTime → entrée évincée → fetch complet requis
main.dart
// Liste de posts : conserver en cache 2 minutes après avoir quitté la page
options: QoraOptions(gcTime: Duration(minutes: 2)),
gcTime: Duration.zero supprime l'entrée dès que tous les abonnés partent. La visite suivante repart toujours de Initial. Ce comportement est correct pour les données sensibles qui ne doivent pas survivre à la session courante.

Partage structurel (Structural Sharing)

Le partage structurel préserve l'égalité référentielle pour les données imbriquées inchangées entre les fetches. Quand un fetch retourne des données profondément égales aux données en cache, Qora conserve la référence précédente au lieu de la remplacer.

Cette fonctionnalité est activée par défaut (QoraOptions.structuralSharing: true). Elle répond directement au problème des reconstructions inutiles de widgets dans les interfaces utilisateur profondément imbriquées :

main.dart
// Sans partage structurel : chaque fetch crée une nouvelle référence,
// ce qui oblige tous les widgets consommant cette requête à se reconstruire.
//
// Avec partage structurel (défaut) : si les données retournées sont
// structurellement identiques aux données en cache, l'ancienne référence
// est conservée. Les widgets qui vérifient identical(oldData, newData)
// ou == ignorent le rendu.

final client = QoraClient();
await client.fetchQuery<Map<String, dynamic>>(
  key: ['config'],
  fetcher: () => api.getConfig(),
  options: QoraOptions(structuralSharing: true),
);

Fonctionnement

La comparaison d'égalité profonde est récursive :

  • Map : compare la présence des clés et les valeurs récursivement
  • List : compare le nombre d'éléments et les éléments récursivement
  • Set : compare l'appartenance
  • Autres types : utilise == (surcharger == et hashCode sur les classes de modèle personnalisées pour de meilleurs résultats)
main.dart
final oldData = {'user': {'name': 'Alice', 'roles': ['admin']}};
final newData = {'user': {'name': 'Alice', 'roles': ['admin']}};

// structuralShare retourne oldData, elles sont profondément égales.
// Tout widget détenant oldData verra identical(oldData, newData) == true.

Quand désactiver

Désactiver le partage structurel uniquement si la vérification d'égalité profonde devient un goulot d'étranglement mesuré avec de très grandes structures de données :

main.dart
QoraOptions(structuralSharing: false),

Portée

Le partage structurel s'applique à tous les chemins d'écriture du cache :

  • fetchQuery après une réponse réseau réussie
  • setQueryData pour les écritures manuelles dans le cache
  • setInfiniteQueryData et updateInfiniteQueryState pour les requêtes infinies
  • hydrateQuery lors de la restauration de données persistées dans une entrée non-initiale

Pipeline de transformation

Qora vous permet de découpler la transformation des données du fetcher via QoraOptions.transform et QoraOptions.transformError. Cela maintient le fetcher simple (appel API brut) et la transformation pure (testable isolément).

transform: succès

Appelée après la réussite du fetcher mais avant l'entrée des données dans le cache (et avant le partage structurel). La valeur transformée est ce qui est mis en cache et retourné aux appelants.

user_repository.dart
fetchQuery<User>(
  key: ['users', userId],
  fetcher: () => api.getUser(userId),  // retourne Map<String, dynamic>
  options: QoraOptions(
    transform: (raw) => User.fromJson(raw as Map<String, dynamic>),
  ),
);

transformError: erreur

Appelée après l'échec de toutes les tentatives mais avant l'entrée de l'erreur dans le cache et avant QoraClientConfig.errorMapper. Utilisez-la pour normaliser les types d'erreur au niveau de la requête.

post_repository.dart
fetchQuery<Post>(
  key: ['post', postId],
  fetcher: () => api.getPost(postId),
  options: QoraOptions(
    transformError: (error) => AppError.fromException(error),
  ),
);

Conserver les données précédentes

Utilisez QoraOptions.keepPreviousData pour supprimer le flash de chargement lors des re-fetches. L'entrée reste dans l'état Success avec les données précédentes pendant que le fetch est en cours, puis passe à Success avec les nouvelles données à la fin du fetch.

C'est particulièrement utile pour la pagination basée sur un offset, où le changement de paramètre de page déclenche un fetch mais vous voulez que la page actuelle reste visible jusqu'à ce que la suivante soit prête :

post_list.dart
fetchQuery<List<Post>>(
  key: ['posts', 'page', page],
  fetcher: () => api.getPosts(page: page, limit: 20),
  options: QoraOptions(
    keepPreviousData: true,
    staleTime: Duration.zero,
  ),
);

En cas d'échec du fetch, l'entrée reste dans Success avec les données précédentes au lieu de passer à Failure. L'erreur est toujours propagée aux appelants impératifs (fetchQuery) afin que la gestion d'erreur (toasts, boutons de reprise) fonctionne normalement.


Éviction LRU

Quand maxCacheSize est défini sur QoraClientConfig, le cache applique une limite via une stratégie LRU (Least Recently Used) :

  • L'entrée accédée le moins récemment et sans abonnés actifs est évincée pour faire de la place
  • Les entrées actives (avec abonnés) ne sont jamais évincées de force
main.dart
final client = QoraClient(
  config: QoraClientConfig(maxCacheSize: 100),
);

Chaque appel à fetchQuery, watchQuery ou getQueryData met à jour lastAccessedAt, maintenant les entrées en vie dans le classement LRU.


Contrôle manuel du cache

Lire le cache

main.dart
// Obtenir uniquement les données (null si pas Success / pas de previousData)
final user = client.getQueryData<User>(['users', userId]);

// Obtenir l'état complet
final state = client.getQueryState<User>(['users', userId]);

// Inspecter toutes les clés en cache
for (final key in client.cachedKeys) {
  print(key);
}

Écrire dans le cache

main.dart
// Injecter des données directement : tous les streams watchQuery actifs sont notifiés
client.setQueryData<User>(['users', userId], updatedUser);

// Rollback vers un snapshot précédent
final snapshot = client.getQueryData<User>(['users', userId]);
client.setQueryData(['users', userId], optimisticUser);

try {
  await api.updateUser(userId, payload);
} catch (_) {
  client.restoreQueryData(['users', userId], snapshot);
}

Invalider (marquer comme périmé)

main.dart
// Clé unique : les streams actifs passent immédiatement à Loading
client.invalidate(['users', userId]);

// Plusieurs clés via prédicat
client.invalidateWhere((key) => key.firstOrNull == 'users');

// Basée sur des tags : revalider toutes les requêtes fournissant le tag 'post'
client.invalidateTags([QueryTag('post')]);

// Ressource spécifique : revalider uniquement les requêtes avec QueryTag('post', '123')
client.invalidateTags([QueryTag('post', '123')]);

L'invalidation basée sur les tags est l'approche la plus flexible. Les requêtes déclarent des tags via providesTags dans QoraOptions. Les mutations déclarent des tags via invalidatesTags dans MutationOptions. Les tags découplent l'invalidation de la structure des clés de requête : vous pouvez invalider des requêtes avec différents motifs de clés en un seul tag.

main.dart
options: QoraOptions(
  providesTags: [QueryTag('post'), QueryTag('user', userId)],
)

Voir MutationOptions.invalidatesTags et QoraOptions.providesTags.

Supprimer et vider

main.dart
// Supprimer une entrée unique (GC immédiat)
client.removeQuery(['users', userId]);

// Vider le cache entier (ex. lors d'une déconnexion)
client.clear();

Callback onCacheEvict

Réagir aux évictions de cache pour le logging ou la synchronisation du stockage :

main.dart
final client = QoraClient(
  config: QoraClientConfig(
    onCacheEvict: (key) {
      analytics.track('cache_evict', {'key': key.toString()});
    },
  ),
);

Snapshot de debug

main.dart
print(client.debugInfo());
// {
//   total_queries: 12,
//   active_queries: 3,
//   inactive_queries: 9,
//   non_success_queries: 1,
//   pending_requests: 2,
//   max_size: 100,
//   keys: [['users', 1], ['posts'], ...]
// }

Configuration globale vs par requête

gcTime et staleTime peuvent être définis globalement via QoraClientConfig.defaultOptions et surchargés par requête :

main.dart
final client = QoraClient(
  config: QoraClientConfig(
    defaultOptions: QoraOptions(
      staleTime: Duration(minutes: 5),  // défaut global
      gcTime: Duration(minutes: 10),
    ),
  ),
);

// Cette requête surcharge uniquement staleTime
await client.fetchQuery<Config>(
  key: ['config'],
  fetcher: api.getConfig,
  options: QoraOptions(staleTime: Duration(hours: 1)),
  // gcTime hérite toujours des 10 minutes du global
);

Étapes suivantes

Copyright © 2026