Reciprok Docs

Concurrence multi-utilisateurs

Comment gérer plusieurs personnes qui éditent la même demande en même temps

Reciprok est utilisé par 3-5 personnes en interne. Le risque de conflit d'édition est faible mais réel : deux utilisateurs ouvrent la même demande, modifient le budget en même temps, qui gagne ?

Le problème

Scénarios concrets :

  1. Édition concurrente d'une demande, Alice modifie le budget pendant que Bob modifie la zone géo
  2. Pipeline du même membre, Alice marque #102 comme "sélectionné" pendant que Bob l'avance à "envoyé"
  3. Réponse à un email, deux personnes répondent au même thread sans le savoir
  4. Catalogue en composition, deux personnes arrangent les recommandés en parallèle
  5. L'IA agit pendant qu'un humain édite, le chat IA modifie le budget pendant qu'un autre utilisateur le modifie aussi

Stratégies envisagées

Option 1, Locks pessimistes (verrous)

Quand un utilisateur ouvre une demande, on pose un verrou. Les autres ne peuvent que lire.

Pour : zéro conflit garanti Contre :

  • Très mauvaise UX (Bob voit "Alice édite cette demande", il attend ?)
  • Verrous orphelins si le navigateur crashe
  • Pas adapté à un workflow collaboratif

Verdict : non.

Option 2, Locks optimistes (versioning)

Chaque entité a une version (int). Toute mutation envoie la version qu'on a lue. Si elle ne match pas, on rejette et l'utilisateur doit re-fetch.

Pour : pas de bloquage, conflits détectés Contre :

  • Frustrant si on perd ses modifs
  • Faut une UX claire pour résoudre les conflits

Verdict : adapté pour les mutations critiques.

Option 3, Realtime sync (CRDT ou OT)

Chaque modification est broadcastée à tous les autres clients via WebSocket. Comme Notion, Linear, Figma.

Pour : meilleure UX possible, pas de conflits visibles Contre :

  • Complexe à implémenter (Y.js, Liveblocks, ou maison)
  • Coût d'infra (WebSocket persistants)
  • Overkill pour 3-5 utilisateurs

Verdict : pas au démarrage. À envisager si l'équipe grossit beaucoup.

Option 4, Last-write-wins (hybride pragmatique)

Le dernier qui sauvegarde gagne, mais on affiche en realtime qui regarde quoi (presence indicator).

Pour : simple, low-tech Contre : risque réel de perdre du travail

Verdict : à éviter pour les actions critiques.

Décision retenue

Approche hybride pragmatique :

  1. Locks optimistes sur les entités critiques :

    • Request (versioning sur le statut, le budget, les dates, les infos clés)
    • RequestResult (versioning sur le pipeline stage)
    • Catalog (versioning sur la composition)
    • Member (versioning sur les infos sensibles : tarifs, capacités)
  2. Last-write-wins pour les champs non-critiques :

    • Notes manuelles
    • Tags (les ajouts/retraits commutent bien)
    • Photos (ajout uniquement, pas de modif)
  3. Presence indicator simple via tRPC subscriptions ou polling :

    • Avatar des utilisateurs actuellement sur la page de la demande
    • "Alice édite cette section" (sur les formulaires actifs)
  4. Notification sur conflit :

    • Si une mutation échoue à cause d'un version mismatch, l'UI affiche un toast clair
    • "Cette demande a été modifiée par Bob il y a 30 secondes. [Voir les modifications] [Réappliquer mes changements]"

Implémentation des locks optimistes

Schéma

Ajout d'une colonne version (int) sur les tables critiques :

export const request = pgTable("request", {
  // ... autres colonnes
  version: integer("version").default(1).notNull(),
});

Mutation côté API

packages/api/src/routers/requests/crud.ts
export const updateRequest = protectedProcedure
  .input(
    z.object({
      id: z.string().uuid(),
      version: z.number().int(),
      patch: requestPatchSchema,
    }),
  )
  .mutation(async ({ input, ctx }) => {
    const result = await db
      .update(request)
      .set({
        ...input.patch,
        version: sql`${request.version} + 1`,
        updatedAt: new Date(),
      })
      .where(
        and(
          eq(request.id, input.id),
          eq(request.version, input.version), // ← le check critique
        ),
      )
      .returning();

    if (result.length === 0) {
      // Soit la demande n'existe pas, soit la version a changé
      const current = await db.query.request.findFirst({
        where: eq(request.id, input.id),
      });

      if (!current) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }

      throw new TRPCError({
        code: "CONFLICT",
        message: "This request was modified by someone else",
        cause: {
          code: "VERSION_MISMATCH",
          currentVersion: current.version,
          yourVersion: input.version,
        },
      });
    }

    return result[0];
  });

UX côté frontend

Quand on reçoit un CONFLICT avec VERSION_MISMATCH :

const updateRequest = api.requests.crud.update.useMutation({
  onError: (error) => {
    if (error.data?.code === "CONFLICT") {
      toast.error("Cette demande a été modifiée par quelqu'un d'autre.", {
        action: {
          label: "Recharger",
          onClick: () => refetchRequest(),
        },
      });
    }
  },
});

Presence indicator (simple)

Approche basique : polling

Toutes les 10 secondes, chaque client envoie un heartbeat :

api.presence.heartbeat.useMutation({
  resourceType: "request",
  resourceId: requestId,
});

Côté serveur, on stocke en mémoire (ou Redis) :

const presence = new Map<string, Set<{ userId: string; lastSeen: Date }>>();

function heartbeat(resourceKey: string, userId: string) {
  const set = presence.get(resourceKey) ?? new Set();
  // ... clean up entries older than 30s
  set.add({ userId, lastSeen: new Date() });
  presence.set(resourceKey, set);
}

Et on expose un endpoint qui retourne qui est présent :

api.presence.list.useQuery({
  resourceType: "request",
  resourceId: requestId,
});

Affichage : avatars des utilisateurs présents en haut à droite de la page.

Approche avancée : tRPC subscriptions

Si on veut du temps réel propre, tRPC supporte les subscriptions via WebSocket. À considérer dans une v2 si l'équipe grossit.

L'IA dans la concurrence

Quand l'IA modifie une entité (via tool call), elle utilise le même mécanisme de versioning. Si un humain édite en parallèle :

  1. L'IA récupère l'état courant avec version
  2. Elle propose une modification (au chat)
  3. L'utilisateur confirme
  4. L'API exécute avec la version d'origine
  5. Si conflit : l'IA reçoit l'erreur et peut proposer une fusion ("Bob a modifié le budget en même temps, je propose de garder ta valeur")

Conséquences

Positives :

  • Pas de perte silencieuse de données
  • UX raisonnable pour 3-5 utilisateurs
  • Implémentation simple (juste un champ version)

Négatives :

  • Pas de "vrai" temps réel (les autres ne voient pas tes modifs en live)
  • Si l'équipe grossit beaucoup, il faudra évoluer vers du realtime sync

Roadmap

  • MVP : versioning + presence polling
  • V1 : presence via tRPC subscriptions
  • V2 (si nécessaire) : realtime sync avec Y.js ou Liveblocks

On this page