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 :
- Édition concurrente d'une demande, Alice modifie le budget pendant que Bob modifie la zone géo
- Pipeline du même membre, Alice marque #102 comme "sélectionné" pendant que Bob l'avance à "envoyé"
- Réponse à un email, deux personnes répondent au même thread sans le savoir
- Catalogue en composition, deux personnes arrangent les recommandés en parallèle
- 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 :
-
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)
-
Last-write-wins pour les champs non-critiques :
- Notes manuelles
- Tags (les ajouts/retraits commutent bien)
- Photos (ajout uniquement, pas de modif)
-
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)
-
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
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 :
- L'IA récupère l'état courant avec
version - Elle propose une modification (au chat)
- L'utilisateur confirme
- L'API exécute avec la version d'origine
- 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