nostr-replaceable-event-mutation-overwrite

Par divinevideo · divine-mobile

Corrige la perte silencieuse de données lors de la mutation d'événements Nostr remplaçables (Kind 0 profil, Kind 3 liste de contacts/abonnements, Kind 10002 liste de relais, etc.) dans les applications clientes. À utiliser quand : (1) Suivre quelqu'un écrase toute la liste d'abonnements de l'utilisateur, (2) La mise à jour des métadonnées de profil fait perdre les champs existants, (3) Une nouvelle session navigateur ou une connexion mobile entraîne une perte de données à la première action, (4) La mutation d'un événement remplaçable utilise un état en cache obsolète ou null. Cause racine : les événements Nostr remplaçables sont en remplacement total (pas de mise à jour partielle), donc publier à partir d'un cache périmé ou non chargé écrase la version canonique. S'applique à tout client Nostr utilisant React, Flutter ou des frameworks réactifs similaires où l'état de la requête peut ne pas être chargé au moment où une mutation se déclenche.

npx skills add https://github.com/divinevideo/divine-mobile --skill nostr-replaceable-event-mutation-overwrite

Surcharge de Mutation d'Événement Remplaçable Nostr

Problème

Les événements remplaçables Nostr (Kind 0, 3, 10002, etc.) utilisent un modèle de remplacement complet : publier un nouvel événement remplace complètement le précédent. Si un client publie une mutation basée sur un état en cache obsolète, incomplet ou nul, il écrase silencieusement la version canonique sur les relais, causant une perte de données. Le cas le plus courant est l'effacement de la liste de suivi (Kind 3) quand un utilisateur suit quelqu'un avant que le client ait chargé sa liste de contacts existante.

Contexte / Conditions de Déclenchement

  • L'utilisateur signale « J'ai suivi quelqu'un et j'ai perdu tous mes autres suivis »
  • Action de suivi/arrêt de suivi sur une session navigateur fraîche ou une connexion mobile
  • La mise à jour de profil perd les champs de métadonnées existants
  • La mise à jour de la liste de relais supprime les relais existants
  • Toute mutation sur un événement remplaçable où l'UI passe un état en cache à la fonction de mutation
  • React Query / TanStack Query data est undefined quand la mutation se déclenche (la requête est toujours en cours de chargement)
  • La fonction de mutation accepte l'événement actuel comme paramètre depuis la couche UI

Solution

1. Toujours Récupérer l'État Frais à l'Intérieur de la Mutation

Ne fiez-vous jamais uniquement à l'état en cache/requête de l'UI. Récupérez la dernière version de l'événement remplaçable directement depuis le relais à l'intérieur de la fonction de mutation, avant de publier :

// MAUVAIS : Dépend du cache UI qui peut être null/obsolète
mutationFn: async ({ targetPubkey, currentContactList }) => {
  const currentTags = currentContactList?.tags || []; // null -> [] -> perte de données !
  // ... publier avec seulement le nouveau suivi
}

// BON : Récupère frais du relais avant de muter
mutationFn: async ({ targetPubkey, currentContactList }) => {
  let bestContactList = currentContactList;

  try {
    const relayEvents = await nostr.query([
      { kinds: [3], authors: [userPubkey], limit: 1 },
    ], { signal: AbortSignal.timeout(5000) });

    const relayContactList = relayEvents
      .sort((a, b) => b.created_at - a.created_at)[0] || null;

    if (relayContactList) {
      // Utilise celle qui a plus de données pour éviter la perte
      const relayCount = relayContactList.tags.filter(t => t[0] === 'p').length;
      const passedCount = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
      if (relayCount >= passedCount) {
        bestContactList = relayContactList;
      }
    }
  } catch {
    // Revenir à la liste de contacts transmise
  }

  if (!bestContactList) {
    throw new Error('Could not load existing data. Please try again.');
  }

  // Maintenant muter bestContactList...
}

2. Stratégie « Le Meilleur des Deux »

Comparez la version du relais contre la version en cache de l'UI et utilisez celle qui a PLUS de données (plus de tags, plus de champs, etc.). Cela protège contre :

  • Relais obsolète (cache UI plus récent à partir d'une action locale récente)
  • Cache UI obsolète (relais a des mises à jour d'un autre client)
  • Cache UI nul (la requête n'a pas chargé sur une session fraîche)

3. Refuser de Publier en Cas d'Échec Total

Si ni la récupération du relais ni le cache UI ne fournissent de données, levez une erreur au lieu de publier un événement remplaçable vide/minimal. Un message d'erreur convivial est toujours préférable à une perte silencieuse de données.

4. Appliquer aux Deux Directions

Appliquez ce motif à TOUTES les directions de mutation (suivi ET arrêt de suivi, ajout ET suppression de relais, mise à jour ET effacement de champs de profil). Le chemin d'arrêt de suivi est tout aussi dangereux que le suivi.

Vérification

  1. Ouvrez l'application dans une fenêtre de navigateur privée/incognito
  2. Connectez-vous avec un compte qui a plusieurs suivis
  3. Naviguez vers un profil et appuyez sur Suivre IMMÉDIATEMENT (avant que la page se charge complètement)
  4. Vérifiez que le nombre de suivis a augmenté de 1 (et non réinitialisé à 1)

Exemple

// Correction réelle du hook useFollowUser de divine-web
export function useFollowUser() {
  const { nostr } = useNostr();

  return useMutation({
    mutationFn: async ({ targetPubkey, currentContactList }) => {
      // Étape 1 : Récupérer frais depuis le relais
      let bestContactList = currentContactList;
      try {
        const events = await nostr.query([
          { kinds: [3], authors: [user.pubkey], limit: 1 }
        ], { signal: AbortSignal.timeout(5000) });
        const relayList = events.sort((a, b) => b.created_at - a.created_at)[0];
        if (relayList) {
          const relayFollows = relayList.tags.filter(t => t[0] === 'p').length;
          const cachedFollows = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
          if (relayFollows >= cachedFollows) bestContactList = relayList;
        }
      } catch { /* revenir au cache */ }

      // Étape 2 : Refuser s'il n'y a pas de données
      if (!bestContactList) throw new Error('Could not load follow list');

      // Étape 3 : Muter en sécurité
      const tags = [...bestContactList.tags, ['p', targetPubkey]];
      return publishEvent({ kind: 3, tags, content: bestContactList.content });
    }
  });
}

Notes

  • Ce motif s'applique à TOUS les kinds d'événements remplaçables Nostr : Kind 0 (profil), Kind 3 (contacts), Kind 10002 (liste de relais), Kind 10000 (liste de sourdine), Kind 30000+ (adressable)
  • La condition de course est la plus courante sur les navigateurs mobiles où le réseau est plus lent et les utilisateurs appuient rapidement
  • Les dialogues de vérification de sécurité (comme « Êtes-vous sûr ? ») ne sont pas utiles car ils vérifient le même cache obsolète – la correction doit être à l'intérieur de la mutation elle-même
  • Le délai d'expiration de 5 secondes sur la récupération du relais est un équilibre raisonnable entre sécurité et UX
  • Envisagez aussi de désactiver le bouton de mutation pendant le chargement de la requête initiale, comme approche de double sécurité

Skills similaires