Files
Toju/toju-app/src/app/store/messages/messages.helpers.ts
2026-06-05 18:34:01 +02:00

264 lines
6.9 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,
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<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.
*
* 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<InventoryItem> {
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<string, number>
): Promise<Map<string, InventoryItem>> {
const map = new Map<string, InventoryItem>();
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<MergeResult> {
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<MergeResult> {
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 };
}