feat: v6.4.0 Realtime production — merge 3-voix (au-delà du LWW) + broadcast non bloquant
FlowDeck CI / lint (push) Successful in 1m25s
FlowDeck CI / test (push) Successful in 10m20s
FlowDeck CI / docker (push) Successful in 1m21s

- app/services/realtime_merge.py : merge à 3 voix diff3-lite, regions disjointes conservees, conflit par champ + drapeau
- protocole base (client embarque la base de sa saisie) ; sans base -> LWW historique (retro-compat)
- ack renvoie le bloc fusionne + conflict ; adoption cote client + toast ; broadcast du resultat fusionne
- broadcast non bloquant : file sortante + tache writer par connexion, coalescence des curseurs
- clients trop lents deconnectes (4413), budget ops anti-flood (400/10s)
- fix fuite room 4404 + room_state() sur page inexistante
- GET /api/realtime/stats (observabilite)
- 26 tests test_realtime_v64.py ; suite 725 verte ; ruff + eslint OK ; version 6.4.0
This commit is contained in:
2026-09-23 23:55:39 -04:00
parent f2e5684e4e
commit f2f2f3209e
11 changed files with 1017 additions and 85 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.3.0",
version="6.4.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+15 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, WebSocket
from fastapi import APIRouter, Request, WebSocket
from starlette.websockets import WebSocketDisconnect
from app.auth.session import SessionManager
@@ -17,6 +17,20 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["realtime"])
@router.get("/api/realtime/stats")
async def realtime_stats(request: Request):
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
Réservé aux utilisateurs authentifiés (données d'activité internes).
"""
user = SessionManager.decode_session(
request.cookies.get("flowdeck_session", "")
)
if not user or not user.get("id"):
return {"error": "unauthorized"}
return manager.stats()
@router.websocket("/ws/pages/{page_id}")
async def ws_page(websocket: WebSocket, page_id: int):
await websocket.accept()
+164
View File
@@ -0,0 +1,164 @@
"""FlowDeck — v6.4.0 Realtime: résolution de conflits au-delà du last-write-wins.
Le LWW par bloc (v5.13.0) écrase intégralement le bloc du dernier arrivé : si deux
utilisateurs tapent dans le *même* bloc, la saisie du premier est perdue. Ce module
implémente un vrai *merge à trois versions* (diff3-lite) :
base = l'état du bloc dont le client dérive sa saisie (envoyé avec l'op)
current = l'état actuel du bloc côté serveur (déjà mis à jour par d'autres)
incoming = la nouvelle proposition du client
Règle champ-par-champ :
* incoming == base → le client n'a pas touché ce champ → on garde current
* current == base → le serveur n'a pas touché ce champ → on garde incoming
* current == incoming → les deux ont fait la même chose → sans conflit
* sinon (conflit)
- champ texte (str) : merge de caractères. Les régions modifiées qui ne se
chevauchent pas sont *toutes conservées* (les deux saisies survivent) ;
chevauchement réel → LWW sur ce champ + drapeau de conflit.
- autre type (bool, nombre…) : LWW sur ce champ + drapeau de conflit.
Toutes les fonctions sont pures et testables sans WebSocket ni base de données.
"""
from __future__ import annotations
from typing import Any
__all__ = ["merge_text_3way", "merge_block_3way", "changed_region"]
def changed_region(base: str, other: str) -> tuple[int, int, str] | None:
"""Région de `base` remplacée par `other` (trim préfixe/suffixe commun).
Retourne ``(start, end, replacement)`` tel que ``base[:start] + replacement +
base[end:] == other``, ou ``None`` si ``other == base`` (aucun changement).
"""
if base == other:
return None
minlen = min(len(base), len(other))
prefix = 0
while prefix < minlen and base[prefix] == other[prefix]:
prefix += 1
suffix = 0
# ne jamais chevaucher le préfixe déjà consommé
while (suffix < len(base) - prefix and suffix < len(other) - prefix
and base[len(base) - 1 - suffix] == other[len(other) - 1 - suffix]):
suffix += 1
return prefix, len(base) - suffix, other[prefix:len(other) - suffix]
def merge_text_3way(base: str, current: str, incoming: str) -> tuple[str, bool]:
"""Merge à trois versions d'une chaîne. Retourne ``(texte, conflit)``.
Les éditions qui ne se chevauchent pas sont toutes les deux conservées ;
un chevauchement réel retombe en LWW (``incoming`` gagne) et signale le conflit.
"""
if current == incoming:
return current, False
if current == base:
return incoming, False
if incoming == base:
return current, False
rc = changed_region(base, current)
ri = changed_region(base, incoming)
if rc is None:
return incoming, False
if ri is None:
return current, False
c_start, c_end, c_text = rc
i_start, i_end, i_text = ri
# Régions disjointes (ou juste adjacentes) → appliquer les deux sur base.
if c_end <= i_start or i_end <= c_start:
edits = sorted([(c_start, c_end, c_text), (i_start, i_end, i_text)],
key=lambda e: e[0])
out: list[str] = []
pos = 0
for start, end, text in edits:
if start < pos:
continue # sécurité: ne jamais réappliquer par-dessus
out.append(base[pos:start])
out.append(text)
pos = end
out.append(base[pos:])
return "".join(out), False
# Chevauchement réel → LWW sur ce champ, conflit signalé.
return incoming, True
def _scalar_conflict(base: Any, current: Any, incoming: Any) -> tuple[Any, bool]:
"""Conflit sur un champ non-texte : LWW (incoming gagne)."""
if current == incoming:
return current, False
if current == base:
return incoming, False
if incoming == base:
return current, False
return incoming, True
def merge_block_3way(base_blk: Any, current_blk: Any,
incoming_blk: Any) -> tuple[dict, list[str]]:
"""Merge à trois versions d'un bloc entier.
Retourne ``(bloc fusionné, champs en conflit)``. Ne lève jamais d'exception :
une entrée non-dict retombe en LWW (``incoming``) avec conflit signalé sur
``__block__`` pour que l'appelant puisse journaliser.
"""
if not isinstance(base_blk, dict):
base_blk = {}
if not isinstance(current_blk, dict):
current_blk = {}
if not isinstance(incoming_blk, dict):
# proposition invalide → on garde l'état serveur
return dict(current_blk), ["__block__"]
keys = set(base_blk) | set(current_blk) | set(incoming_blk)
merged: dict[str, Any] = {}
conflicts: list[str] = []
for key in keys:
b = base_blk.get(key)
c = current_blk.get(key)
i = incoming_blk.get(key)
if i == b:
# le client n'a pas modifié ce champ → valeur serveur. Si le serveur
# a *supprimé* le champ (absent de current) alors la suppression doit
# gagner : on n'insère pas de clé fantôme value=None.
if key not in current_blk:
continue
merged[key] = c
elif c == b:
# le serveur n'a pas modifié ce champ → valeur client
merged[key] = i
elif c == i:
merged[key] = c
else:
# les deux ont changé, différemment
if isinstance(b, str) and isinstance(c, str) and isinstance(i, str):
text, conflict = merge_text_3way(b, c, i)
merged[key] = text
if conflict:
conflicts.append(key)
else:
value, conflict = _scalar_conflict(b, c, i)
merged[key] = value
if conflict:
conflicts.append(key)
# Un champ supprimé par le serveur et absent de la proposition client doit
# rester supprimé (pas de réapparition d'une valeur None fantôme).
merged = {k: v for k, v in merged.items()
if not (v is None and k not in incoming_blk)}
if "id" not in merged:
# garantir l'identité du bloc même si base était vide
ident = incoming_blk.get("id") or current_blk.get("id")
if ident:
merged["id"] = ident
return merged, conflicts
+265 -73
View File
@@ -1,5 +1,23 @@
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
merge des opérations de blocs (last-write-wins par bloc) + version de page.
"""FlowDeck — v6.4.0 Realtime: WebSocket gateway, présences, curseurs live,
merge à trois versions des opérations de blocs (au-delà du last-write-wins) +
version de page.
Améliorations « production » par rapport à v5.13.0 :
* **Conflits** — les mises à jour de bloc embarquent la ``base`` dont dérive la
saisie du client ; le serveur fait un merge 3-voix champ-par-champ + texte
(voir ``realtime_merge``) au lieu d'écraser le bloc entier. Les deux saisies
disjointes survivent, les chevauchements réels retombent en LWW *par champ*
avec drapeau de conflit renvoyé au client.
* **Échelle** — chaque connexion possède une file sortante + une tâche writer
dédiée ; le broadcast devient non bloquant (un client lent ne bloque plus la
room), les mises à jour de curseur se coalescent (une seule par flush), et un
client trop lent (file pleine) est déconnecté proprement (4413).
* **Anti-flood** — budget d'opérations par connexion (fenêtre glissante).
* **Fuites corrigées** — une room 4404 n'est plus enregistrée ; ``room_state``
ne crée plus d'objet None ; les rooms vides sont évacuées.
* **Observabilité** — compteurs (rooms, conns, ops, merges, conflits, déconnexions
lentes) exposés par ``stats()``.
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
avec debounce. Fallback polling côté client si le WS est indisponible.
@@ -9,16 +27,25 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from fastapi import WebSocket
from app.db import get_conn
from app.services.realtime_merge import merge_block_3way
logger = logging.getLogger(__name__)
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
# File sortante maximale par connexion au-delà de laquelle le client est
# considéré comme trop lent et déconnecté (évite qu'une room entière stagne).
MAX_OUT_QUEUE = 512
# Nombre maximal d'opérations acceptées par connexion et par fenêtre (anti-flood).
OP_WINDOW_SECONDS = 10.0
OP_WINDOW_MAX = 400
def color_for(uid: int) -> str:
return COLORS[(uid or 0) % len(COLORS)]
@@ -48,7 +75,11 @@ def ensure_block_ids(blocks: list[dict]) -> list[dict]:
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
"""Apply one block op (insert/update/delete/move) — LWW par bloc.
Conservé pour la compatibilité : tests et chemins sans ``base`` continuent
de fonctionner. Le merge 3-voix vit dans ``RealtimeManager._apply``.
"""
t = op.get("type")
if t == "insert":
blk = op.get("block") or {}
@@ -90,9 +121,40 @@ def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
return out
class RTConn:
"""Une connexion WS : file sortante + tâche writer dédiée.
Le broadcast ne fait que ``put_nowait`` dans la file ; c'est la tâche writer
qui consomme et écrit sur le socket. Un client lent n'empêche donc jamais
les autres membres de la room de recevoir les messages.
"""
__slots__ = ("ws", "user", "page_id", "out_q", "writer", "closed",
"_ops_count", "_ops_window_start", "created_at")
def __init__(self, ws: WebSocket, user: dict, page_id: int):
self.ws = ws
self.user = user
self.page_id = page_id
self.out_q: asyncio.Queue = asyncio.Queue(maxsize=MAX_OUT_QUEUE)
self.writer: asyncio.Task | None = None
self.closed = False
self._ops_count = 0
self._ops_window_start = time.monotonic()
self.created_at = time.monotonic()
def op_budget_ok(self) -> bool:
"""Fenêtre glissante simple anti-flood d'opérations."""
now = time.monotonic()
if now - self._ops_window_start > OP_WINDOW_SECONDS:
self._ops_window_start = now
self._ops_count = 0
self._ops_count += 1
return self._ops_count <= OP_WINDOW_MAX
class Room:
__slots__ = ("page_id", "blocks", "title", "version", "conns",
"persist_task", "dirty")
"persist_task", "dirty", "merge_count", "conflict_count")
def __init__(self, page_id: int):
self.page_id = page_id
@@ -102,21 +164,21 @@ class Room:
self.conns: set[RTConn] = set()
self.persist_task: asyncio.Task | None = None
self.dirty = False
class RTConn:
__slots__ = ("ws", "user", "page_id")
def __init__(self, ws: WebSocket, user: dict, page_id: int):
self.ws = ws
self.user = user
self.page_id = page_id
self.merge_count = 0
self.conflict_count = 0
class RealtimeManager:
def __init__(self):
self._rooms: dict[int, Room] = {}
# compteurs globaux (observabilité)
self.stat_ops = 0
self.stat_merges = 0
self.stat_conflicts = 0
self.stat_slow_disconnects = 0
self.stat_connections_total = 0
# ── rooms ────────────────────────────────────────────────────────────
def room(self, page_id: int) -> Room:
return self._rooms.setdefault(page_id, Room(page_id))
@@ -150,42 +212,86 @@ class RealtimeManager:
room.blocks = []
return True
# ── entrées / sorties ────────────────────────────────────────────────
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
room = self.room(page_id)
# Ne JAMAIS enregistrer la room avant d'avoir validé l'existence de la
# page : avant, un 4404 laissait une Room orpheline en mémoire pour
# toujours (fuite).
existing = self._rooms.get(page_id)
room = existing if existing is not None else Room(page_id)
if not room.conns and not await self.load_room(room):
await ws.close(code=4404)
return None
return None # room non enregistrée → pas de fuite
if existing is None:
self._rooms[page_id] = room
conn = RTConn(ws, user, page_id)
self.stat_connections_total += 1
conn.writer = asyncio.create_task(self._writer(conn))
room.conns.add(conn)
me = self._peer(user)
peers = [self._peer(c.user) for c in room.conns if c is not conn]
await ws.send_json({"t": "welcome", "self": me,
"peers": peers, "color": me["color"]})
await ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
for c in room.conns:
if c is not conn:
try:
await c.ws.send_json({"t": "peer_join", "peer": me})
except Exception:
pass
await self._broadcast(room, {"t": "peer_join", "peer": me}, exclude=conn)
return conn
async def disconnect(self, conn: RTConn):
room = self._rooms.get(conn.page_id)
if not room:
if room is None:
self._close_writer(conn)
return
room.conns.discard(conn)
for c in room.conns:
try:
await c.ws.send_json({"t": "peer_leave",
"id": conn.user.get("id") or 0})
except Exception:
pass
self._close_writer(conn)
await self._broadcast(room, {"t": "peer_leave",
"id": conn.user.get("id") or 0})
if not room.conns:
await self.flush(room)
self._rooms.pop(conn.page_id, None)
def _close_writer(self, conn: RTConn):
conn.closed = True
t = conn.writer
if t is not None and not t.done():
t.cancel()
conn.writer = None
async def _writer(self, conn: RTConn):
"""Tâche dédiée : consomme la file sortante et écrit sur le socket.
Sert aussi de filet de coalescence : après chaque message consommé, les
mises à jour de curseur (``sel``) déjà empilées sont réduites à la
dernière (les curseurs n'ont pas besoin d'être ordonnés entre eux, seule
la position la plus récente compte).
"""
try:
while not conn.closed:
msg = await conn.out_q.get()
if msg is None:
return
await conn.ws.send_json(msg)
# coalescence des curseurs en attente
pending_sel = []
while not conn.out_q.empty():
nxt = conn.out_q.get_nowait()
if nxt is None:
return
if isinstance(nxt, dict) and nxt.get("t") == "sel":
pending_sel.append(nxt)
else:
await conn.ws.send_json(nxt)
if pending_sel:
await conn.ws.send_json(pending_sel[-1])
except asyncio.CancelledError:
raise
except Exception as e: # socket mort → on arrête proprement
logger.debug("writer stopped: %s", e)
conn.closed = True
# ── persistance ──────────────────────────────────────────────────────
async def flush(self, room: Room):
"""Write current room state to DB (sync, used on idle + disconnect)."""
if room.persist_task and not room.persist_task.done():
@@ -218,67 +324,102 @@ class RealtimeManager:
room.persist_task = asyncio.create_task(_run())
# ── broadcast non bloquant ───────────────────────────────────────────
def _enqueue(self, conn: RTConn, msg: dict) -> bool:
"""Pose le message dans la file du client. Retourne False si trop lent."""
if conn.closed:
return False
try:
conn.out_q.put_nowait(msg)
return True
except asyncio.QueueFull:
return False
async def _evict_slow(self, room: Room, conn: RTConn):
"""Client dépassé : on le déconnecte pour ne pas figer la room."""
self.stat_slow_disconnects += 1
logger.info("realtime: evicting slow client (user=%s page=%s)",
conn.user.get("login"), conn.page_id)
room.conns.discard(conn)
self._close_writer(conn)
try:
await conn.ws.close(code=4413)
except Exception:
pass
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
for c in room.conns:
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
slow: list[RTConn] = []
for c in list(room.conns):
if c is exclude:
continue
try:
await c.ws.send_json(msg)
except Exception:
pass
if not self._enqueue(c, msg):
slow.append(c)
for c in slow:
await self._evict_slow(room, c)
async def _send(self, conn: RTConn, msg: dict):
if not self._enqueue(conn, msg):
room = self._rooms.get(conn.page_id)
if room is not None:
await self._evict_slow(room, conn)
# ── protocole ────────────────────────────────────────────────────────
async def handle(self, conn: RTConn, msg: dict):
room = self._rooms.get(conn.page_id)
if not room:
if room is None:
return
t = msg.get("t")
me = (conn.user.get("id") or 0)
if t == "hello":
try:
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
except Exception:
pass
await self._send(conn, {"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
return
if t == "sync_req":
try:
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
except Exception:
pass
await self._send(conn, {"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
return
if t == "ping":
try:
await conn.ws.send_json({"t": "pong"})
except Exception:
pass
await self._send(conn, {"t": "pong"})
return
if t == "op":
if not conn.op_budget_ok():
# trop d'ops : on ignore silencieusement (le client resync)
await self._send(conn, {"t": "ack", "v": room.version,
"stale": True})
return
op = msg.get("op") or {}
client_v = msg.get("v", 0)
room.blocks = apply_op(room.blocks, op)
result = self._apply(room, op)
room.version += 1
self.stat_ops += 1
self._schedule_persist(room)
stale = client_v < room.version - 1
await self._broadcast(room, {"t": "op", "op": op, "from": me,
"v": room.version}, exclude=conn)
try:
await conn.ws.send_json({"t": "ack", "v": room.version,
"stale": stale})
except Exception:
pass
# broadcast : on diffuse TOUJOURS le bloc final fusionné (pas la
# proposition brute) pour que tous les clients convergent.
out_op = dict(op)
if result.get("merged") is not None:
out_op["block"] = result["merged"]
out_op["merged"] = True
await self._broadcast(room, {"t": "op", "op": out_op, "from": me,
"v": room.version,
"conflict": result.get("conflict", False)},
exclude=conn)
ack = {"t": "ack", "v": room.version, "stale": stale}
if result.get("merged") is not None:
ack["merged"] = result["merged"]
ack["conflict"] = result.get("conflict", False)
await self._send(conn, ack)
if stale:
try:
await conn.ws.send_json({"t": "sync",
"blocks": room.blocks,
"title": room.title,
"version": room.version})
except Exception:
pass
await self._send(conn, {"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
return
if t == "title":
@@ -289,11 +430,7 @@ class RealtimeManager:
self._schedule_persist(room)
await self._broadcast(room, {"t": "title", "title": room.title,
"from": me, "v": room.version}, exclude=conn)
try:
await conn.ws.send_json({"t": "ack", "v": room.version,
"stale": False})
except Exception:
pass
await self._send(conn, {"t": "ack", "v": room.version, "stale": False})
return
if t == "sel":
@@ -303,16 +440,72 @@ class RealtimeManager:
"offset": msg.get("offset", 0)}, exclude=conn)
return
def _apply(self, room: Room, op: dict) -> dict:
"""Applique une opération en mergeant à 3 voix si le client fournit
une ``base``. Retourne ``{"merged": bloc|None, "conflict": bool}``.
* ``update`` avec ``base`` → merge 3-voix (au-delà du LWW).
* le reste (insert/delete/move, ou update sans base) → LWW historique.
"""
if op.get("type") != "update" or "base" not in op:
room.blocks = apply_op(room.blocks, op)
return {"merged": None, "conflict": False}
incoming = op.get("block") or {}
base = op.get("base")
bid = incoming.get("id")
if not bid:
return {"merged": None, "conflict": False}
current = next((b for b in room.blocks if b.get("id") == bid), None)
if current is None:
# bloc introuvable : LWW historique = pas d'update possible
return {"merged": None, "conflict": False}
merged, conflicts = merge_block_3way(base, current, incoming)
room.merge_count += 1
conflict = bool(conflicts)
if conflict:
room.conflict_count += 1
self.stat_conflicts += 1
logger.debug("realtime conflict page=%s block=%s fields=%s",
room.page_id, bid, conflicts)
self.stat_merges += 1
room.blocks = [merged if b.get("id") == bid else b for b in room.blocks]
return {"merged": merged, "conflict": conflict}
# ── observabilité ────────────────────────────────────────────────────
async def room_state(self, page_id: int) -> dict:
room = self._rooms.get(page_id)
if not room.conns and not room.blocks:
if room is None:
# ne pas créer d'objet None : on charge dans une room jetable
room = Room(page_id)
if not await self.load_room(room):
return {"blocks": [], "title": "", "version": 0}
elif not room.conns and not room.blocks:
await self.load_room(room)
return {"blocks": room.blocks, "title": room.title, "version": room.version}
def stats(self) -> dict:
rooms = len(self._rooms)
conns = sum(len(r.conns) for r in self._rooms.values())
return {
"rooms": rooms,
"connections": conns,
"connections_total": self.stat_connections_total,
"ops": self.stat_ops,
"merges": self.stat_merges,
"conflicts": self.stat_conflicts,
"slow_disconnects": self.stat_slow_disconnects,
"pages": [{"page_id": r.page_id, "conns": len(r.conns),
"version": r.version, "merges": r.merge_count,
"conflicts": r.conflict_count}
for r in self._rooms.values()],
}
async def _propagate_synced(self, synced_id: int) -> None:
"""Broadcast a synced-block update to all rooms that reference it."""
from app.db import get_conn as _get_conn
# Find all pages that reference this synced block
pages: list[int] = []
try:
with _get_conn() as conn:
@@ -326,11 +519,10 @@ class RealtimeManager:
for pid in pages:
room = self._rooms.get(pid)
if room and room.conns:
# Re-load the page content from DB to get fresh synced blocks
await self.load_room(room)
await self._broadcast(room, {"t": "synced_update",
"synced_id": synced_id,
"version": room.version})
"synced_id": synced_id,
"version": room.version})
manager = RealtimeManager()
+24 -1
View File
@@ -110,7 +110,9 @@ window.__fdRT = (function () {
cur.forEach(b => {
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
ops.push({ type: 'update', block: clone(b) });
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
}
});
base.forEach(b => {
@@ -358,6 +360,27 @@ window.__fdRT = (function () {
} else if (m.t === 'ack') {
version = m.v || 0;
if (pendingOps > 0) pendingOps--;
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
// c'est qu'un autre utilisateur avait modifié le même bloc.
if (m.merged) {
const mid = m.merged.id;
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
base = applyOpJS(base, { type: 'update', block: m.merged });
if (differs && E) {
const fid = activeBlockId();
if (fid !== mid) {
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
E.dirty = true;
E.render();
refocus(fid);
}
if (m.conflict && window.showToast) {
window.showToast('Editing conflict merged on a block', 'info');
}
}
}
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
} else if (m.t === 'op') {
if (m.v) version = m.v;