/** * Message store helpers - delegates pure domain logic to `domains/chat/domain/` * and provides DB-dependent hydration/merge operations at the application level. */ import { Message, type MessageRevision } 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'; import { computeMessageHeadHashFromMessage, getMessageRevision, shouldApplyIncomingRevision } from '../../domains/chat/domain/rules/message-integrity.rules'; import { materializeMessageFromRevision, revisionBeatsMessage } from '../../domains/chat/domain/rules/message-revision.builder.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 { const revision = getMessageRevision(msg); const headHash = msg.headHash ?? await computeMessageHeadHashFromMessage(msg, revision); if (msg.isDeleted) { return { id: msg.id, ts: getMessageTimestamp(msg), rc: 0, ac: 0, revision, headHash }; } return { id: msg.id, ts: getMessageTimestamp(msg), rc: msg.reactions?.length ?? 0, ac: attachmentCountOverride ?? 0, revision, headHash }; } /** 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) { const item = await buildInventoryItem( msg, _db, attachmentCountOverrides?.get(msg.id) ); map.set(msg.id, item); } 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. */ function shouldApplyIncomingMessage(incoming: Message, existing: Message | null): boolean { const incomingRevision = getMessageRevision(incoming); const existingRevision = getMessageRevision(existing ?? undefined); if (incoming.headHash) { const existingHeadHash = existing?.headHash ?? ''; return shouldApplyIncomingRevision( incomingRevision, existingRevision, incoming.headHash, existingHeadHash ); } const existingTs = existing ? getMessageTimestamp(existing) : -1; const incomingTs = getMessageTimestamp(incoming); const isDeletedStateNewer = !!existing && incomingTs === existingTs && incoming.isDeleted && !existing.isDeleted; return !existing || incomingTs > existingTs || isDeletedStateNewer; } export async function mergeIncomingRevision( revision: MessageRevision, db: DatabaseService ): Promise { const existing = await db.getMessageById(revision.messageId); if (!revisionBeatsMessage(revision, existing)) { if (!existing) { return { message: materializeMessageFromRevision(null, revision), changed: false }; } return { message: normaliseDeletedMessage(existing), changed: false }; } const message = materializeMessageFromRevision(existing, revision); await db.saveMessage(message); await db.saveMessageRevision(revision); if (message.isDeleted) { return { message: normaliseDeletedMessage(message), changed: true }; } const reactions = await db.getReactionsForMessage(message.id); return { message: { ...message, reactions }, changed: true }; } export async function mergeIncomingMessage( incoming: Message, db: DatabaseService ): Promise { const existing = await db.getMessageById(incoming.id); const isNewer = shouldApplyIncomingMessage(incoming, existing); if (isNewer) { const persisted = incoming.headHash ? incoming : { ...incoming, revision: getMessageRevision(incoming), headHash: await computeMessageHeadHashFromMessage(incoming, getMessageRevision(incoming)) }; await db.saveMessage(persisted); } // 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 }; }