193 lines
7.5 KiB
TypeScript
193 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|