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

@@ -139,6 +139,7 @@ export function registerMessengerMessageRoutes(router: Router): void {
size: z.number().default(0),
mimeType: z.string().optional(),
})).optional(),
clientMessageId: z.string().max(64).optional(),
});
router.post("/api/messenger/conversations/:id/messages",
@@ -171,17 +172,60 @@ export function registerMessengerMessageRoutes(router: Router): void {
const orgId = conv?.organizationId ?? req.organizationId!;
const [newMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: userId,
message: message.trim(),
replyToId: replyToId ?? null,
mentionedUserIds: mentionedUserIds?.length ? mentionedUserIds : null,
attachments: attachments?.length ? attachments : null,
})
.returning();
// Идемпотентность: повтор с тем же clientMessageId — вернуть уже созданное
// сообщение БЕЗ повторного insert и без side-эффектов (SSE/уведомления
// уже отработали при первой записи). Защита от дублей при multi-tab replay
// офлайн-очереди (миграция 0087).
const clientMessageId = parsed.data.clientMessageId ?? null;
if (clientMessageId) {
const [existing] = await db
.select({ id: conversationMessages.id })
.from(conversationMessages)
.where(and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.clientMessageId, clientMessageId)
))
.limit(1);
if (existing) {
const enrichedExisting = await enrichMessage(existing.id);
return res.status(200).json({ success: true, message: enrichedExisting, deduplicated: true });
}
}
let newMsg;
try {
[newMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: userId,
message: message.trim(),
replyToId: replyToId ?? null,
mentionedUserIds: mentionedUserIds?.length ? mentionedUserIds : null,
attachments: attachments?.length ? attachments : null,
clientMessageId,
})
.returning();
} catch (err) {
// Гонка: параллельный запрос с тем же clientMessageId уже вставил
// сообщение (unique index conv_messages_client_message_id_unique).
const e = err as { code?: string; cause?: { code?: string } };
if (clientMessageId && (e?.code === '23505' || e?.cause?.code === '23505')) {
const [existing] = await db
.select({ id: conversationMessages.id })
.from(conversationMessages)
.where(and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.clientMessageId, clientMessageId)
))
.limit(1);
if (existing) {
const enrichedExisting = await enrichMessage(existing.id);
return res.status(200).json({ success: true, message: enrichedExisting, deduplicated: true });
}
}
throw err;
}
const enriched = await enrichMessage(newMsg.id);
const members = await getMembersForSSE(convId, orgId);