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

@@ -964,7 +964,7 @@ export function useChatController() {
};
const sendMutation = useMutation({
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[] }) => {
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[]; clientMessageId?: string }) => {
const res = await apiRequest('POST', `/api/messenger/conversations/${activeConvId}/messages`, payload);
const data = await res.json();
if (!data.success) throw new Error(data.error);
@@ -1180,6 +1180,9 @@ export function useChatController() {
replyToId: replyTo?.id,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
attachments: undefined,
// Ключ идемпотентности: реплеи офлайн-очереди (в т.ч. из разных вкладок)
// идут с одним ключом → серверный дедуп по client_message_id (миграция 0087)
clientMessageId: crypto.randomUUID(),
});
setMentionedUserIds([]);
setMentionSearch(null);

View File

@@ -86,6 +86,17 @@ function updateAppBadge(n: number) {
}
}
// Кросс-вкладочный мьютекс на реплей офлайн-очереди (Web Locks API). Без него N
// открытых вкладок реплеят одну и ту же IndexedDB-очередь НЕЗАВИСИМО (модульный
// _syncLock свой у каждой вкладки) → дубли сообщений/мутаций на сервере.
// Fallback для сред без Web Locks — старое поведение (дубли гасит серверная
// идемпотентность по clientMessageId, миграции 0086/0087).
async function withCrossTabSyncLock<T>(fn: () => Promise<T>): Promise<T> {
const locks = (navigator as unknown as { locks?: { request: (name: string, cb: () => Promise<T>) => Promise<T> } }).locks;
if (!locks?.request) return fn();
return locks.request('iistwin-offline-sync-replay', fn);
}
async function replayRequest(item: OfflineQueueItem): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -257,10 +268,8 @@ async function trySendAttachmentMessage(
};
// Идемпотентность: id записи очереди стабилен между реплеями (в т.ч. из разных
// вкладок) — сервер дедупит повторы по client_message_id (миграция 0086).
if (item.type === 'task') {
payload.clientMessageId = (item as { clientMessageId?: string }).clientMessageId ?? item.id;
}
// вкладок) — сервер дедупит повторы по client_message_id (миграции 0086/0087).
payload.clientMessageId = (item as { clientMessageId?: string }).clientMessageId ?? item.id;
if (item.type === 'messenger' && (item.replyToId || item.replyToMessageId)) {
payload.replyToId = item.replyToId || item.replyToMessageId;
@@ -355,7 +364,7 @@ export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
return;
}
_syncLock = (async () => {
_syncLock = (async () => withCrossTabSyncLock(async () => {
setStatus('syncing');
try {
@@ -564,7 +573,7 @@ export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
} finally {
_syncLock = null;
}
})();
}))();
await _syncLock;
}, []);