All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
187 lines
5.1 KiB
TypeScript
187 lines
5.1 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.
|
|
*
|
|
* 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> {
|
|
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<string, number>
|
|
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
|
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
|
|
|
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<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 };
|
|
}
|