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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user