fix(server): use two-step MAX(created_at) for DM last message in ready payload

Aligns the ready payload query with the GET /api/dm approach:
get MAX(created_at) per channel first, then fetch the actual
message rows. Avoids issues with federated relay messages whose
local snowflake IDs don't match chronological order.
This commit is contained in:
Jannis Braun
2026-04-03 00:04:40 +02:00
parent d7f82d5496
commit 5e778f400b
+19 -18
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js'; import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { eq, and, inArray, isNull, desc, sql } from 'drizzle-orm'; import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js'; import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type { import type {
@@ -1142,28 +1142,28 @@ function buildReadyPayload(userId: string): {
const dmUserMap = new Map(allDmUsers.map(u => [u.id, u])); const dmUserMap = new Map(allDmUsers.map(u => [u.id, u]));
// Batch: last message per DM channel. // Batch: last message per DM channel.
// Use MAX(created_at) instead of MAX(id) because federated relay messages // Two-step approach (same as GET /api/dm): get MAX(created_at) per channel,
// can have local snowflake IDs that don't match chronological order — a // then fetch the actual message rows matching those timestamps.
// message sent earlier on the remote instance can arrive (and get a higher const dmMaxTimestamps = batchInArray(
// local ID) after a message sent later. The DM REST API already uses
// ORDER BY created_at DESC, so this keeps the ready payload consistent.
const dmLastMsgIdRows = batchInArray(
dmChannelIds, dmChannelIds,
ids => db.select({ ids => db.select({
dmChannelId: schema.dmMessages.dmChannelId, dmChannelId: schema.dmMessages.dmChannelId,
lastId: sql<string>`( maxCreatedAt: sql<number>`MAX(${schema.dmMessages.createdAt})`.as('max_created_at'),
SELECT id FROM ${schema.dmMessages} sub
WHERE sub.dm_channel_id = ${schema.dmMessages.dmChannelId}
ORDER BY sub.created_at DESC, sub.id DESC
LIMIT 1
)`,
}).from(schema.dmMessages).where(inArray(schema.dmMessages.dmChannelId, ids)).groupBy(schema.dmMessages.dmChannelId).all(), }).from(schema.dmMessages).where(inArray(schema.dmMessages.dmChannelId, ids)).groupBy(schema.dmMessages.dmChannelId).all(),
); );
const dmLastMsgIds = dmLastMsgIdRows.map(r => r.lastId).filter((id): id is string => id != null); const dmLastMsgMap = new Map<string, typeof schema.dmMessages.$inferSelect>();
const dmLastMessages = dmLastMsgIds.length > 0 if (dmMaxTimestamps.length > 0) {
? batchInArray(dmLastMsgIds, ids => db.select().from(schema.dmMessages).where(inArray(schema.dmMessages.id, ids)).all()) const conditions = dmMaxTimestamps.map(t =>
: []; and(eq(schema.dmMessages.dmChannelId, t.dmChannelId), eq(schema.dmMessages.createdAt, t.maxCreatedAt!))
const dmLastMsgMap = new Map(dmLastMessages.map(m => [m.dmChannelId, m])); );
const dmLastMessages = db.select().from(schema.dmMessages).where(or(...conditions)).all();
for (const m of dmLastMessages) {
if (!dmLastMsgMap.has(m.dmChannelId)) {
dmLastMsgMap.set(m.dmChannelId, m);
}
}
}
const dmLastMsgIds = [...dmLastMsgMap.values()].map(m => m.id);
// Batch: attachments for last messages (1 query) // Batch: attachments for last messages (1 query)
const dmLastMsgAttachments = dmLastMsgIds.length > 0 const dmLastMsgAttachments = dmLastMsgIds.length > 0
@@ -1211,6 +1211,7 @@ function buildReadyPayload(userId: string): {
} : null, } : null,
}); });
} }
} }
// Get Space Folders // Get Space Folders