Skip to content

Conversation persistence

Everything so far lives in memory — reload the page and the thread is gone. aparté’s persistence subsystem closes that gap. A ConversationManager holds the list of conversations and notifies listeners on every change, but never touches storage directly — that’s the job of an AparteStorageAdapter you implement against any backend (localStorage, IndexedDB, SQLite WASM, your own REST API). A conversationId binding loads/persists the active thread; <aparte-conversation-list> renders a sidebar to switch between them.

The contract has three required methods — together they’re the minimum viable adapter:

interface AparteStorageAdapter {
loadAll(): Promise<AparteConversation[]>; // all conversations, full payload, newest first
save(conv: AparteConversation): Promise<void>; // upsert (create or update)
delete(id: string): Promise<void>; // permanent delete
// + optional split-storage extensions: archive?/unarchive?, loadMeta?/loadFull?,
// pin?/unpin?, rename?, and memory / settings / artifact-gallery / attachment rows.
}

An AparteConversation carries id, title, createdAt, updatedAt, a flat messages array (the active path — always kept for sidebar previews and compat), an optional tree (full branch topology, see Conversations & branching), and optional archivedAt / pinnedAt / folderId / schemaVersion (current version is 2; treat undefined as legacy data).

Here’s a complete adapter over localStorage, implementing the three required methods plus the optional archive / unarchive pair:

import type { AparteConversation, AparteStorageAdapter } from '@aparte/core';
const KEY = 'aparte:conversations';
function readAll(): AparteConversation[] {
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); }
catch { return []; }
}
function writeAll(convs: AparteConversation[]): void {
localStorage.setItem(KEY, JSON.stringify(convs));
}
export class LocalStorageAdapter implements AparteStorageAdapter {
async loadAll(): Promise<AparteConversation[]> {
return readAll().sort((a, b) => b.updatedAt - a.updatedAt);
}
async save(conv: AparteConversation): Promise<void> {
const all = readAll();
const i = all.findIndex((c) => c.id === conv.id);
if (i >= 0) all[i] = conv; else all.push(conv);
writeAll(all);
}
async delete(id: string): Promise<void> {
writeAll(readAll().filter((c) => c.id !== id));
}
// Optional — without them, archiving falls back to a plain save().
async archive(id: string): Promise<void> { this._setArchived(id, Date.now()); }
async unarchive(id: string): Promise<void> { this._setArchived(id, undefined); }
private _setArchived(id: string, archivedAt: number | undefined): void {
const all = readAll();
const conv = all.find((c) => c.id === id);
if (conv) { conv.archivedAt = archivedAt; writeAll(all); }
}
}

An IndexedDB (or SQLite WASM) adapter follows the same shape — every method is async precisely so any backend fits. Richer backends can additionally implement loadMeta() / loadFull(id) (fast sidebar listing vs. lazy full payload), pin / unpin / rename, and the memory-fact / settings / gallery methods — all optional, consulted only when present.

ConversationManager owns the in-memory list, mutates it, and calls your adapter — your app never calls the adapter directly.

import { AparteConfig, ConversationManager } from '@aparte/core';
import { LocalStorageAdapter } from './local-storage-adapter';
const manager = new ConversationManager(new LocalStorageAdapter());
await manager.init(); // hydrates the list from the adapter
AparteConfig.setConversationManager(manager); // registers it for every <aparte-*> component

Running several independently-configured chats on one page? Call setConversationManager on each chat’s own AparteConfigClass instance (passed as config) instead of the global singleton.

Useful reads once registered: manager.conversations, manager.activeConversations / manager.archivedConversations (newest first), manager.activeId, manager.active, and manager.subscribe(listener) (returns an unsubscribe fn; fires after every mutation). Mutations: createNew(title?), delete(id), archive(id), unarchive(id).

Optional bounded history: new ConversationManager(adapter, { retention: { maxMessages: 200 } }) trims a persisted conversation to its last N messages on every write — storage only, the live session in the DOM is never truncated.

3. Load and persist the active thread — conversationId

Section titled “3. Load and persist the active thread — conversationId”

Each wrapper’s <AparteChat> exposes a conversationId binding. Setting it loads that conversation’s messages (and branch tree, if any) via the registered manager; setting it to null deselects. Sending the first message while conversationId is unset lazily creates a conversation and reports the new id back, so you can sync a URL/router.

// React — Vue/Svelte/Angular expose the same pair under their idiomatic names:
// Vue :conversation-id / @conversation-created
// Svelte conversationId / on:conversationCreated
// Angular [conversationId] / (conversationCreated)
const [conversationId, setConversationId] = useState<string | null>(null);
<AparteChat conversationId={conversationId} onConversationCreated={setConversationId} />

Without a wrapper, drive the same lifecycle yourself with AparteConversationController — it’s exactly what the wrappers use internally:

import { AparteConversationController } from '@aparte/core';
const chat = document.querySelector('aparte-chat')!;
const viewport = (chat as any).viewport;
const controller = new AparteConversationController({
hostId: 'main-chat',
host: chat,
getMessages: () => viewport.getMessages(),
setMessages: (m) => viewport.setMessages(m),
appendMessage: (m) => viewport.appendMessage(m),
clearMessages: () => viewport.clearMessages(),
exportTree: () => viewport.exportTree(), // optional — enables branch persistence
importTree: (t) => viewport.importTree(t), // optional
});
controller.bind();
void controller.setConversationId('abc-123'); // or null to deselect

<aparte-conversation-list> is a display primitive: set its conversations property and active-id attribute, and handle the four events it emits (all bubble, detail: { id }): aparte-select-conversation, aparte-delete-conversation, aparte-archive-conversation, aparte-unarchive-conversation.

const list = document.querySelector('aparte-conversation-list')!;
function render() {
(list as any).conversations = manager.activeConversations;
list.setAttribute('active-id', manager.activeId ?? '');
}
manager.subscribe(render);
render();
list.addEventListener('aparte-delete-conversation', (e) => manager.delete((e as CustomEvent).detail.id));
list.addEventListener('aparte-archive-conversation', (e) => manager.archive((e as CustomEvent).detail.id));
list.addEventListener('aparte-unarchive-conversation', (e) => manager.unarchive((e as CustomEvent).detail.id));
// Selecting a conversation is owned by the `conversationId` binding (or a window-level
// `aparte-select-conversation` event that every bound controller listens for), not the list.

Each wrapper ships a reactive helper around the same manager — call its init(adapter) once instead of constructing ConversationManager by hand (step 2 is done for you), then bind conversations / activeConversations / archivedConversations to the list and createNew / addMessage / updateMessages / delete / archive / unarchive to actions:

WrapperHelper
ReactuseConversationManager() — hook, plain state
VueuseConversationManager() — composable, refs/computed
SveltecreateConversationManager() — stores
AngularConversationManagerService — injectable (providedIn: 'root')

Switching the active conversation stays owned by the conversationId binding — the helpers deliberately don’t expose a select().


See the generated Elements reference for <aparte-conversation-list>’s exact property/attribute signatures, and Conversations & branching for exportTree() / importTree(), which this subsystem persists as the tree field.