181 lines
5.0 KiB
TypeScript
181 lines
5.0 KiB
TypeScript
/**
|
|
* 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<Message> {
|
|
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<Message[]> {
|
|
return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg);
|
|
}
|
|
|
|
/** Builds a sync inventory item from a message and its reaction count. */
|
|
export async function buildInventoryItem(
|
|
msg: Message,
|
|
db: DatabaseService,
|
|
attachmentCountOverride?: number
|
|
): Promise<InventoryItem> {
|
|
if (msg.isDeleted) {
|
|
return {
|
|
id: msg.id,
|
|
ts: getMessageTimestamp(msg),
|
|
rc: 0,
|
|
ac: 0
|
|
};
|
|
}
|
|
|
|
const reactions = await db.getReactionsForMessage(msg.id);
|
|
const attachments =
|
|
attachmentCountOverride === undefined
|
|
? await db.getAttachmentsForMessage(msg.id)
|
|
: [];
|
|
|
|
return { id: msg.id,
|
|
ts: getMessageTimestamp(msg),
|
|
rc: reactions.length,
|
|
ac: attachmentCountOverride ?? attachments.length };
|
|
}
|
|
|
|
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */
|
|
export async function buildLocalInventoryMap(
|
|
messages: Message[],
|
|
db: DatabaseService,
|
|
attachmentCountOverrides?: ReadonlyMap<string, number>
|
|
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
|
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
|
|
|
await Promise.all(
|
|
messages.map(async (msg) => {
|
|
if (msg.isDeleted) {
|
|
map.set(msg.id, {
|
|
ts: getMessageTimestamp(msg),
|
|
rc: 0,
|
|
ac: 0
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const reactions = await db.getReactionsForMessage(msg.id);
|
|
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
|
|
const attachments =
|
|
attachmentCountOverride === undefined
|
|
? await db.getAttachmentsForMessage(msg.id)
|
|
: [];
|
|
|
|
map.set(msg.id, { ts: getMessageTimestamp(msg),
|
|
rc: reactions.length,
|
|
ac: attachmentCountOverride ?? attachments.length });
|
|
})
|
|
);
|
|
|
|
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<MergeResult> {
|
|
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 };
|
|
}
|