fix(chat): cross-tab lock реплея офлайн-очереди + идемпотентность мессенджера (conv_messages)

Клиентская часть гонки (вариант B): Web Locks API (navigator.locks) —
кросс-вкладочный мьютекс iistwin-offline-sync-replay на replay-проход в
useOfflineSync: первая вкладка под локом реплеит и удаляет записи, остальные
видят пустую очередь. Fallback для сред без Web Locks — старое поведение
(дубли гасит серверная идемпотентность). Race-тест: без locks → 2 POST,
с locks → 1 POST для одной записи очереди.

Мессенджер (conv_messages) — та же защита, что у task_messages:
- conversation_messages.client_message_id + partial unique (миграция 0087);
- POST /api/messenger/conversations/:id/messages: повтор с тем же
  clientMessageId → существующее сообщение без insert и без SSE/уведомлений,
  гонка insert'ов ловится по 23505;
- useChatController шлёт crypto.randomUUID(), attachment-реплей мессенджера
  тоже использует id записи очереди.
This commit is contained in:
2026-09-29 20:31:59 +03:00
parent af4850bd6d
commit 9827b5df33
6 changed files with 141 additions and 22 deletions

View File

@@ -0,0 +1,10 @@
-- Идемпотентность сообщений мессенджера (conversation_messages): клиентский ключ.
-- Та же защита, что у task_messages (миграция 0086): multi-tab replay
-- офлайн-очереди, ретраи после сетевых сбоев, двойные клики.
-- Partial unique index: NULL-значения (старые сообщения, боты) не конфликтуют.
ALTER TABLE conversation_messages ADD COLUMN IF NOT EXISTS client_message_id varchar(64);
CREATE UNIQUE INDEX IF NOT EXISTS conv_messages_client_message_id_unique
ON conversation_messages (client_message_id)
WHERE client_message_id IS NOT NULL;