Skip to content

Persist chat conversations (IndexedDB, SQLite, REST)

Everything so far lives in memory — reload the page and the thread is gone. aparté’s persistence subsystem closes that gap. A AparteConversationManager 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 loadAttachments? for persisted attachment blobs.
}

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).

The current value is exported as APARTE_CONVERSATION_SCHEMA_VERSION, so an adapter can compare it against what it stored and migrate instead of guessing.

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 loadAttachments(msgId) for attachment blobs — all optional, consulted only when present. Anything beyond the conversation — a memory of user facts, a settings store, a gallery of every artifact ever produced — is your app’s own table: extend the interface in your code rather than expecting the library to carry your schema.

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

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

Running several independently-configured chats on one page? Call setConversationManager on each chat’s own AparteConfig 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 AparteConversationManager(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.

There is no binding without a wrapper — the pair is a wrapper convenience over AparteConversationController, which is the next section on this page.

controller.bind();
void controller.setConversationId('abc-123'); // or null to deselect

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 events it emits (all bubble, detail: { id } — rename adds title). Each row is its title plus a menu: rename, pin/unpin, archive/unarchive, delete (which asks first). The rows are grouped by date as soon as any item carries updatedAt; set no-groups to render them flat.

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-rename-conversation', (e) => manager.updateTitle(e.detail.id, e.detail.title));
list.addEventListener('aparte-pin-conversation', (e) => manager.pin(e.detail.id));
list.addEventListener('aparte-unpin-conversation', (e) => manager.unpin(e.detail.id));
list.addEventListener('aparte-archive-conversation', (e) => manager.archive(e.detail.id));
list.addEventListener('aparte-unarchive-conversation', (e) => manager.unarchive(e.detail.id));
list.addEventListener('aparte-delete-conversation', (e) => manager.delete(e.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 AparteConversationManager by hand (step 2 is done for you), then bind conversations / activeConversations / archivedConversations to the list and createNew / addMessage / updateMessages / updateTitle / pin / unpin / 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().

Your adapter stores AparteMessage objects verbatim, segments included, and hands them back. Core then adopts them rather than treating them as new — and the difference is worth knowing, because two of the fields on a segment are facts and two are measurements.

on a restored segmentwhat happenswhy
id, type, content, …yours, untouchedit is your data
messageId, indexrecomputed from the array being joinedderivable facts. A stored value can only contradict the list it lands in — and no protocol persists either; Anthropic’s block index exists solely inside the streaming envelope, as a position
meta.aparte.startedAt / endedAtnot restored, and not inventeda span is something the client measured while the turn ran. A measurement nobody took is absent
isStreamingforced to falsea persisted stream is dead. Restored as streaming it would render a caret for ever, and the next completed turn would stamp it a brand-new end
meta.* (yours)round-trips as storedyour half of the bag

Nothing is lost that was ever there. Most backends store messages and have never heard of a segment — content parts carry no timestamp in any wire format — so there is usually nothing to lose. What changed is that core no longer fills the gap with now: a conversation from three weeks ago used to come back claiming every segment had started that second, and only on some of the load paths, so the same stored thread produced different numbers depending on whether you were in native or framework-managed mode and on whether a tree had been saved.

A request that outlived its page is closed for you. A tool_call persisted as awaiting-approval comes back as aborted. The loop that awaited the decision went with the page, so nothing can answer it — and aborted rather than rejected because nobody refused anything. Every load path shares one normalisation, so this holds whichever of them your adapter uses.

One thing core still cannot fix for you. A tool_call persisted as pending comes back with a spinner and an open span, because the handler that was running is likewise gone. If your adapter can be interrupted mid-turn, normalise that status on save.


See its element page for the exact property and attribute signatures, and Conversations & branching for exportTree() / importTree(), which this subsystem persists as the tree field.