/** * Message store helpers - delegates pure domain logic to `domains/chat/domain/` * and provides DB-dependent hydration/merge operations at the application level. */ import { Message } from '../../shared-kernel'; import { DatabaseService } from '../../infrastructure/persistence'; import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/rules/message.rules'; import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules'; // Re-export domain logic so existing callers keep working export { getMessageTimestamp, getLatestTimestamp, normaliseDeletedMessage, canEditMessage } from '../../domains/chat/domain/rules/message.rules'; export { INVENTORY_LIMIT, CHUNK_SIZE, SYNC_POLL_FAST_MS, SYNC_POLL_SLOW_MS, SYNC_TIMEOUT_MS, FULL_SYNC_LIMIT, chunkArray, findMissingIds } from '../../domains/chat/domain/rules/message-sync.rules'; export type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules'; /** Hydrates a single message with its reactions from the database. */ export async function hydrateMessage( msg: Message, _db: DatabaseService ): Promise { if (msg.isDeleted) return normaliseDeletedMessage(msg); return msg; } /** Hydrates an array of messages with their reactions. */ export async function hydrateMessages( messages: Message[], _db: DatabaseService ): Promise { return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg); } /** Builds a sync inventory item from a message and its reaction count. * * Reactions are read from the already-hydrated `msg.reactions` array (the * persistence layer joins them in via `getMessages`), and attachment counts * only come from the in-memory override. We deliberately avoid per-message * DB lookups here so a whole-room inventory stays O(1) DB calls even when * the room contains tens of thousands of messages. */ export async function buildInventoryItem( msg: Message, _db: DatabaseService, attachmentCountOverride?: number ): Promise { if (msg.isDeleted) { return { id: msg.id, ts: getMessageTimestamp(msg), rc: 0, ac: 0 }; } const item: InventoryItem = { id: msg.id, ts: getMessageTimestamp(msg), rc: msg.reactions?.length ?? 0 }; if (attachmentCountOverride !== undefined) { item.ac = attachmentCountOverride; } return item; } /** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. * * As with {@link buildInventoryItem}, reactions come from the already-hydrated * `msg.reactions` array and attachment counts only come from the in-memory * override map. */ export async function buildLocalInventoryMap( messages: Message[], _db: DatabaseService, attachmentCountOverrides?: ReadonlyMap ): Promise> { const map = new Map(); for (const msg of messages) { if (msg.isDeleted) { map.set(msg.id, { ts: getMessageTimestamp(msg), rc: 0, ac: 0 }); continue; } map.set(msg.id, { ts: getMessageTimestamp(msg), rc: msg.reactions?.length ?? 0, ac: attachmentCountOverrides?.get(msg.id) ?? 0 }); } return map; } /** Result of merging an incoming message into the local database. */ export interface MergeResult { message: Message; changed: boolean; } /** * Merges an incoming message into the local database. * Handles message upsert and reaction deduplication, then returns * the fully hydrated message alongside a `changed` flag. */ export async function mergeIncomingMessage( incoming: Message, db: DatabaseService ): Promise { const existing = await db.getMessageById(incoming.id); const existingTs = existing ? getMessageTimestamp(existing) : -1; const incomingTs = getMessageTimestamp(incoming); const isDeletedStateNewer = !!existing && incomingTs === existingTs && incoming.isDeleted && !existing.isDeleted; const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer; if (isNewer) { await db.saveMessage(incoming); } // Persist incoming reactions (deduped by the DB layer) const incomingReactions = incoming.isDeleted ? [] : incoming.reactions ?? []; for (const reaction of incomingReactions) { await db.saveReaction(reaction); } const changed = isNewer || incomingReactions.length > 0; if (changed) { const baseMessage = isNewer ? incoming : existing; if (!baseMessage) { return { message: normaliseDeletedMessage(incoming), changed }; } if (baseMessage.isDeleted) { return { message: normaliseDeletedMessage(baseMessage), changed }; } const reactions = await db.getReactionsForMessage(incoming.id); return { message: { ...baseMessage, reactions }, changed }; } if (!existing) { return { message: normaliseDeletedMessage(incoming), changed: false }; } return { message: normaliseDeletedMessage(existing), changed: false }; }