import type { ConversationItem, DeliveryState } from "../protocol"; export type StoredConversationItem = { local_id: string; item: ConversationItem; created_at: string; message_seq?: number; }; export type OutboxEntry = { local_id: string; payload: string; created_at: string; }; export type ConversationSnapshot = { conversation_id: string; cursor: number; items: readonly StoredConversationItem[]; outbox: readonly OutboxEntry[]; }; type PersistedConversation = { version: 3; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; }; const STORE_PREFIX = "lineup.conversation.v1"; /** * Conversation-local state and its browser persistence adapter. * * It stores only validated ConversationItems and outgoing plaintext awaiting * transport acknowledgement. It has no DOM, renderer, HTTP, or IM dependency. * A native Store can implement the same snapshot semantics in M4 hosts. */ export class ConversationStore { private activeConversationID: string | undefined; private state: PersistedConversation = { version: 3, cursor: 0, items: [], outbox: [] }; public constructor(private readonly storage: Storage) {} public open(conversationID: string): ConversationSnapshot { this.activeConversationID = conversationID; this.state = this.load(conversationID); return this.snapshot(); } public close(): void { this.activeConversationID = undefined; this.state = { version: 3, cursor: 0, items: [], outbox: [] }; } public snapshot(): ConversationSnapshot { return { conversation_id: this.requireActiveConversationID(), cursor: this.state.cursor, items: this.state.items.map(item => ({ ...item })), outbox: this.state.outbox.map(entry => ({ ...entry })), }; } public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { local_id: localID, created_at: createdAt, item: { kind: "user-message", id: localID, text, delivery }, }; this.state.items.push(stored); this.state.outbox.push({ local_id: localID, payload: text, created_at: createdAt }); this.persist(); return { ...stored }; } /** Transport acknowledgement gives the local message its durable IM sequence. */ public markSubmitted(localID: string, messageSeq: number, updatedAt: string): void { const stored = this.state.items.find(item => item.local_id === localID); if (!stored || stored.item.kind !== "user-message") return; stored.message_seq = messageSeq; stored.item = { ...stored.item, delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) }, }; this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID); // A send acknowledgement is not a sync acknowledgement. The legacy // `/messages/sync` cursor is inclusive, so advancing it here would make a // refresh start at `messageSeq + 1` and permanently skip this user's echo. // Only a message that has actually passed through the sync path advances // the cursor (recordSyncedUserText / recordIncoming / advanceCursor). this.persist(); } public markFailed(localID: string, error: string, updatedAt: string): void { const stored = this.state.items.find(item => item.local_id === localID); if (!stored || stored.item.kind !== "user-message") return; stored.item = { ...stored.item, delivery: { status: "failed", updated_at: updatedAt, error, retryable: true }, }; this.persist(); } public advanceCursor(messageSeq: number): void { if (messageSeq <= this.state.cursor) return; this.state.cursor = messageSeq; this.persist(); } /** * Inserts a remote item exactly once. Sequence is the primary IM identity; * an envelope id catches duplicate messages arriving through another source. */ public recordIncoming(item: ConversationItem, messageSeq: number, createdAt: string): boolean { if (this.state.items.some(existing => existing.message_seq === messageSeq)) { this.state.cursor = Math.max(this.state.cursor, messageSeq); this.persist(); return false; } if (item.id && this.state.items.some(existing => existing.item.id === item.id)) { this.state.cursor = Math.max(this.state.cursor, messageSeq); this.persist(); return false; } this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq, }); this.state.cursor = Math.max(this.state.cursor, messageSeq); this.persist(); return true; } /** Restores a user message sent by this client when no local record exists. */ public recordSyncedUserText(messageSeq: number, text: string, createdAt: string): StoredConversationItem | undefined { if (this.state.items.some(existing => existing.message_seq === messageSeq)) { this.state.cursor = Math.max(this.state.cursor, messageSeq); this.persist(); return undefined; } const item: ConversationItem = { kind: "user-message", id: `remote_${messageSeq}`, text, delivery: { status: "delivered", updated_at: createdAt, transport_id: String(messageSeq) }, }; this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq }); this.state.cursor = Math.max(this.state.cursor, messageSeq); this.persist(); return { local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq }; } private load(conversationID: string): PersistedConversation { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 3, cursor: 0, items: [], outbox: [] }; const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown }; if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { return { version: 3, cursor: 0, items: [], outbox: [] }; } if (candidate.version !== 3) { // v1 advanced its cursor from a send acknowledgement. Some browsers // had already been upgraded to v2 before that correction, retaining a // high cursor with holes for their own echoes. Preserve every valid // local item but replay the server history once to repair those holes; // the next persist upgrades this record to v3. return { version: 3, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; } return { version: 3, cursor: Math.max(0, candidate.cursor), items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; } catch { return { version: 3, cursor: 0, items: [], outbox: [] }; } } private persist(): void { this.storage.setItem(this.key(this.requireActiveConversationID()), JSON.stringify(this.state)); } private key(conversationID: string): string { return `${STORE_PREFIX}:${conversationID}`; } private requireActiveConversationID(): string { if (!this.activeConversationID) throw new Error("ConversationStore has no active conversation."); return this.activeConversationID; } }