export type ChatDeliveryState = 'sending' | 'sent' | 'delivered' | 'read' | 'failed'; export interface ChatReceiptReaderState { membership_id?: string; principal_id?: string; reader_id?: string; reader_kind?: string; display_name?: string; name?: string; avatar_url?: string; delivered_at?: string; read_at?: string; [key: string]: unknown; } export interface ChatReceiptSummaryState { revision?: string | number; policy?: 'any' | 'all'; recipient_count?: number; recipients_total?: number; delivered_count?: number; read_count?: number; delivered_all?: boolean; read_all?: boolean; delivered_at?: string; read_at?: string; read_by?: ChatReceiptReaderState[]; [key: string]: unknown; } export type ChatTranscriptStatus = | 'pending' | 'queued' | 'processing' | 'completed' | 'ready' | 'failed' | 'unavailable'; export interface ChatTranscriptState { status?: ChatTranscriptStatus; text?: string; revision?: string | number; updated_at?: string; error?: string; [key: string]: unknown; } export interface ChatAttachmentProjection { attachment_id?: string; client_upload_id?: string; transcript?: ChatTranscriptState; transcript_status?: ChatTranscriptStatus; transcript_text?: string; transcript_revision?: string | number; transcription?: { state?: 'pending' | 'processing' | 'completed' | 'failed'; text?: string; revision?: string | number; language?: string; updated_at?: string; error?: string; }; } export interface ChatMessageProjection { message_id?: string; client_message_id?: string; delivery_status?: ChatDeliveryState; receipt_summary?: ChatReceiptSummaryState; read_by?: ChatReceiptReaderState[]; transcript?: ChatTranscriptState; attachments?: ChatAttachmentProjection[]; link_previews?: unknown[]; metadata?: Record; } function definedProperties(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined) ) as Partial; } function revisionValue(value: unknown): string { if (typeof value === 'number' && Number.isFinite(value)) return String(value); return typeof value === 'string' ? value.trim() : ''; } /** Compares opaque revisions while keeping decimal sequence revisions exact. */ export function compareChatRevisions(left: unknown, right: unknown): number { const a = revisionValue(left); const b = revisionValue(right); if (a === b) return 0; if (!a) return -1; if (!b) return 1; if (/^\d+$/.test(a) && /^\d+$/.test(b)) { try { const leftNumber = BigInt(a); const rightNumber = BigInt(b); return leftNumber === rightNumber ? 0 : leftNumber < rightNumber ? -1 : 1; } catch { // Fall through for runtimes without BigInt support. } } return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); } function latestTimestamp(left?: string, right?: string): string | undefined { if (!left) return right; if (!right) return left; const leftTime = Date.parse(left); const rightTime = Date.parse(right); if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { return rightTime > leftTime ? right : left; } return right > left ? right : left; } function earliestTimestamp(left?: string, right?: string): string | undefined { if (!left) return right; if (!right) return left; const leftTime = Date.parse(left); const rightTime = Date.parse(right); if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { return rightTime < leftTime ? right : left; } return right < left ? right : left; } function maxCount(left: unknown, right: unknown): number | undefined { const values = [left, right].filter((value): value is number => ( typeof value === 'number' && Number.isFinite(value) && value >= 0 )); return values.length > 0 ? Math.max(...values) : undefined; } function readerKey(reader: ChatReceiptReaderState, index: number): string { return reader.membership_id || reader.principal_id || reader.reader_id || `${reader.reader_kind || 'reader'}:${reader.display_name || reader.name || index}`; } export function mergeChatReceiptReaders( previous: ChatReceiptReaderState[] = [], incoming: ChatReceiptReaderState[] = [] ): ChatReceiptReaderState[] { const readers = new Map(); previous.forEach((reader, index) => readers.set(readerKey(reader, index), reader)); incoming.forEach((reader, index) => { const key = readerKey(reader, previous.length + index); const existing = readers.get(key); readers.set(key, existing ? { ...existing, ...definedProperties(reader), delivered_at: earliestTimestamp(existing.delivered_at, reader.delivered_at), read_at: earliestTimestamp(existing.read_at, reader.read_at), } : reader); }); return Array.from(readers.values()).sort((left, right) => { const leftTime = left.read_at || left.delivered_at || ''; const rightTime = right.read_at || right.delivered_at || ''; return leftTime.localeCompare(rightTime); }); } export function mergeChatReceiptSummary( previous?: ChatReceiptSummaryState, incoming?: ChatReceiptSummaryState ): ChatReceiptSummaryState | undefined { if (!previous) return incoming; if (!incoming) return previous; const revisionComparison = compareChatRevisions(incoming.revision, previous.revision); const preferred = revisionComparison >= 0 ? incoming : previous; const fallback = preferred === incoming ? previous : incoming; const readBy = mergeChatReceiptReaders(previous.read_by, incoming.read_by); const previousTotal = maxCount(previous.recipients_total, previous.recipient_count); const incomingTotal = maxCount(incoming.recipients_total, incoming.recipient_count); const recipientsTotal = maxCount(previousTotal, incomingTotal); const recipientCount = recipientsTotal; const deliveredCount = maxCount(previous.delivered_count, incoming.delivered_count); const readCount = maxCount(previous.read_count, incoming.read_count); const deliveredAll = Boolean(previous.delivered_all || incoming.delivered_all) || Boolean(recipientsTotal && deliveredCount === recipientsTotal); const readAll = Boolean(previous.read_all || incoming.read_all) || Boolean(recipientsTotal && readCount === recipientsTotal); return { ...definedProperties(fallback), ...definedProperties(preferred), revision: preferred.revision ?? fallback.revision, recipients_total: recipientsTotal, recipient_count: recipientCount, delivered_count: deliveredCount, read_count: readCount, delivered_all: deliveredAll, read_all: readAll, delivered_at: earliestTimestamp(previous.delivered_at, incoming.delivered_at), read_at: earliestTimestamp(previous.read_at, incoming.read_at), read_by: readBy.length > 0 ? readBy : undefined, }; } const deliveryRank: Record, number> = { sending: 0, sent: 1, delivered: 2, read: 3, }; export function mergeChatDeliveryState( previous?: ChatDeliveryState, incoming?: ChatDeliveryState ): ChatDeliveryState | undefined { if (!previous) return incoming; if (!incoming) return previous; if (incoming === 'failed') return previous === 'sending' || previous === 'failed' ? 'failed' : previous; if (previous === 'failed') return incoming === 'sending' ? previous : incoming; return deliveryRank[incoming] >= deliveryRank[previous] ? incoming : previous; } const transcriptRank: Record = { pending: 0, queued: 0, processing: 1, failed: 2, unavailable: 2, completed: 3, ready: 3, }; export function mergeChatTranscript( previous?: ChatTranscriptState, incoming?: ChatTranscriptState ): ChatTranscriptState | undefined { if (!previous) return incoming; if (!incoming) return previous; const revisionComparison = compareChatRevisions(incoming.revision, previous.revision); let preferred = incoming; if (revisionComparison < 0) { preferred = previous; } else if (revisionComparison === 0) { const previousTime = previous.updated_at ? Date.parse(previous.updated_at) : Number.NaN; const incomingTime = incoming.updated_at ? Date.parse(incoming.updated_at) : Number.NaN; if (Number.isFinite(previousTime) && Number.isFinite(incomingTime)) { preferred = incomingTime >= previousTime ? incoming : previous; } else { const previousRank = previous.status ? transcriptRank[previous.status] : -1; const incomingRank = incoming.status ? transcriptRank[incoming.status] : -1; preferred = incomingRank >= previousRank ? incoming : previous; } } const fallback = preferred === incoming ? previous : incoming; return { ...definedProperties(fallback), ...definedProperties(preferred), revision: preferred.revision ?? fallback.revision, text: preferred.text || fallback.text, updated_at: latestTimestamp(previous.updated_at, incoming.updated_at), error: preferred.status === 'ready' ? undefined : (preferred.error ?? fallback.error), }; } function attachmentTranscript(attachment?: ChatAttachmentProjection): ChatTranscriptState | undefined { if (!attachment) return undefined; if (attachment.transcript) return attachment.transcript; if (attachment.transcription) { return { status: attachment.transcription.state === 'completed' ? 'ready' : attachment.transcription.state, text: attachment.transcription.text, revision: attachment.transcription.revision, updated_at: attachment.transcription.updated_at, error: attachment.transcription.error, }; } if (!attachment.transcript_status && !attachment.transcript_text && attachment.transcript_revision === undefined) { return undefined; } return { status: attachment.transcript_status, text: attachment.transcript_text, revision: attachment.transcript_revision, }; } export function mergeChatAttachments( previous: T[] = [], incoming: T[] = [] ): T[] { if (incoming.length === 0) return previous; const attachments = new Map(); previous.forEach((attachment, index) => { attachments.set(attachment.attachment_id || attachment.client_upload_id || `previous:${index}`, attachment); }); incoming.forEach((attachment, index) => { const key = attachment.attachment_id || attachment.client_upload_id || `incoming:${index}`; const existing = attachments.get(key); if (!existing) { attachments.set(key, attachment); return; } const transcript = mergeChatTranscript(attachmentTranscript(existing), attachmentTranscript(attachment)); const transcriptionState = transcript?.status === 'ready' ? 'completed' : transcript?.status; attachments.set(key, { ...existing, ...definedProperties(attachment), transcript, transcript_status: transcript?.status, transcript_text: transcript?.text, transcript_revision: transcript?.revision, transcription: transcript ? { ...(existing.transcription ?? {}), ...(attachment.transcription ?? {}), state: transcriptionState === 'pending' || transcriptionState === 'processing' || transcriptionState === 'completed' || transcriptionState === 'failed' ? transcriptionState : undefined, text: transcript.text, revision: transcript.revision, updated_at: transcript.updated_at, error: transcript.error, } : undefined, }); }); return Array.from(attachments.values()); } /** * Merges a message projection without allowing delayed snapshots/events to * regress delivery, receipt or transcript state. */ export function mergeChatMessageProjection(previous: T, incoming: T): T { const receiptSummary = mergeChatReceiptSummary(previous.receipt_summary, incoming.receipt_summary); const readBy = mergeChatReceiptReaders( previous.read_by ?? previous.receipt_summary?.read_by, incoming.read_by ?? incoming.receipt_summary?.read_by ); return { ...previous, ...definedProperties(incoming), delivery_status: mergeChatDeliveryState(previous.delivery_status, incoming.delivery_status), receipt_summary: receiptSummary, read_by: readBy.length > 0 ? readBy : undefined, transcript: mergeChatTranscript(previous.transcript, incoming.transcript), attachments: mergeChatAttachments(previous.attachments, incoming.attachments), link_previews: incoming.link_previews?.length ? incoming.link_previews : previous.link_previews, metadata: { ...(previous.metadata ?? {}), ...(incoming.metadata ?? {}) }, } as T; }