Skip to Content
APIUtilitiesThreading & Persistence

Threading & Persistence

The library provides a flexible thread persistence system that allows you to store chat conversations in any backend. By default, it uses IndexedDB for browser-based persistence, but you can implement custom adapters for databases like SQLite, PostgreSQL, or cloud storage.

Types

ChatThreadRecord

Metadata for a thread that can be loaded independently from its messages. This is what powers thread lists, sorting, and rename logic.

interface ChatThreadRecord { id: string; parentId?: string; scopeKey?: string; title?: string; createdAt: number; updatedAt: number; messageCount: number; messageSignature: string; metadata?: Record<string, unknown>; }

ChatThreadSummary

Alias of ChatThreadRecord. The store returns summaries when listing threads for a scope.

ChatThreadTimeline

Snapshot of a thread’s ordered messages.

interface ChatThreadTimeline { threadId: string; signature: string; messages: UIMessage[]; updatedAt: number; }

ChatThread

Combined convenience shape containing a record and an optional timeline when the messages are loaded.

interface ChatThread { record: ChatThreadRecord; timeline?: ChatThreadTimeline; }

ChatThreadPersistence

Interface for implementing custom thread storage adapters.

interface ChatThreadPersistence { loadSummaries(scopeKey?: string): Promise<ChatThreadSummary[]>; loadTimeline(threadId: string): Promise<ChatThreadTimeline | null>; saveRecord(record: ChatThreadRecord): Promise<void>; saveTimeline(timeline: ChatThreadTimeline): Promise<void>; deleteThread(threadId: string): Promise<void>; }

CreateThreadOptions

Options for creating new threads.

interface CreateThreadOptions { id?: string; // Custom ID (auto-generated if omitted) parentId?: string; // Parent thread for branching scopeKey?: string; // Partition key title?: string; // Initial title initialMessages?: UIMessage[]; // Starting messages metadata?: Record<string, unknown>; // Custom metadata }

CloneThreadOptions

Options for cloning/branching existing threads.

interface CloneThreadOptions { newId?: string; // ID for the cloned thread parentId?: string; // Override parent (defaults to source ID) scopeKey?: string; // Override scope titleSuffix?: string; // Append to title (e.g., "(branch)") trimLastAssistantIfStreaming?: boolean; // Remove incomplete message }

Built-in Persistence

createIndexedDBChatThreadPersistence

Creates an IndexedDB-backed persistence adapter.

import { createIndexedDBChatThreadPersistence } from "ai-chat-bootstrap"; const persistence = createIndexedDBChatThreadPersistence({ dbName: "my-app-threads", version: 1, });

getDefaultChatThreadPersistence

Returns the default IndexedDB persistence instance (lazy-initialized).

import { getDefaultChatThreadPersistence } from "ai-chat-bootstrap"; const persistence = getDefaultChatThreadPersistence();

Custom Persistence Example

Here’s a complete example implementing thread persistence with a custom backend:

import { ChatThreadPersistence, normalizeMessagesMetadata, useChatThreadsStore, } from "ai-chat-bootstrap"; // Example: SQLite persistence (pseudo-code) const sqlitePersistence: ChatThreadPersistence = { async loadSummaries(scopeKey) { const rows = await db.query( `SELECT id, parentId, scopeKey, title, createdAt, updatedAt, messageCount, messageSignature, metadata FROM threads WHERE scopeKey = ? ORDER BY updatedAt DESC`, [scopeKey ?? "default"] ); return rows.map((row) => ({ ...row, metadata: JSON.parse(row.metadata ?? "{}"), })); }, async loadTimeline(threadId) { const row = await db.queryOne( `SELECT signature, messages, updatedAt FROM thread_messages WHERE threadId = ?`, [threadId] ); if (!row) return null; return { threadId, signature: row.signature, messages: JSON.parse(row.messages), updatedAt: row.updatedAt, }; }, async saveRecord(record) { await db.run( `INSERT OR REPLACE INTO threads (id, parentId, scopeKey, title, createdAt, updatedAt, messageCount, messageSignature, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ record.id, record.parentId, record.scopeKey ?? "default", record.title, record.createdAt, record.updatedAt, record.messageCount, record.messageSignature, JSON.stringify(record.metadata ?? {}), ] ); }, async saveTimeline(timeline) { const { messages } = normalizeMessagesMetadata(timeline.messages); await db.run( `INSERT OR REPLACE INTO thread_messages (threadId, signature, messages, updatedAt) VALUES (?, ?, ?, ?)`, [ timeline.threadId, timeline.signature, JSON.stringify(messages), timeline.updatedAt, ] ); }, async deleteThread(id) { await db.run("DELETE FROM threads WHERE id = ?", [id]); await db.run("DELETE FROM thread_messages WHERE threadId = ?", [id]); }, }; // Initialize the store with custom persistence useChatThreadsStore.getState().initializePersistent(sqlitePersistence);

Initialization

Initialize your custom persistence adapter before mounting ChatContainer:

import { useChatThreadsStore } from "ai-chat-bootstrap"; import { customPersistence } from "./persistence"; // In your app initialization useChatThreadsStore.getState().initializePersistent(customPersistence); export default function App() { return <ChatContainer threads={{ enabled: true }} />; }

ℹ️ If threads are enabled but no persistence adapter is available, the store will now log a warning and fall back to ephemeral mode. Always wire up an adapter (or call initializeEphemeral) in environments where IndexedDB is unavailable.

💡 Implementing persistence in a Node/Edge context? Import helpers from the server-friendly bundle: import { normalizeMessagesMetadata } from "ai-chat-bootstrap/server"; to avoid pulling React components into route handlers.

Scoped Threads

Use scopeKey to partition threads by workspace, notebook, or project:

<ChatContainer threads={{ enabled: true, scopeKey: "notebook-123", // All threads scoped to this notebook }} />

Threads with different scope keys are isolated and won’t appear in each other’s thread lists.

Best Practices

  1. Always normalize messages - Use normalizeMessagesMetadata before saving timelines to ensure consistent metadata:

    const { messages } = normalizeMessagesMetadata(timeline.messages); await persistence.saveTimeline({ ...timeline, messages });
  2. Implement loadSummaries efficiently - Return only the record fields needed to render thread lists. Avoid fetching full messages during summary loads.

  3. Return the full truth - loadSummaries should return the complete set of threads for the scope. The store removes anything missing from the response and promotes the most recently updated thread automatically.

  4. Hydrate timelines - loadTimeline should surface the latest messages. The store eagerly calls it for the active thread after loadSummaries, so provide canonical data.

  5. Handle errors gracefully - Wrap persistence calls in try-catch blocks to handle storage failures.

  6. Use transactions - When using SQL databases, wrap multiple operations in transactions for consistency.

  7. Index key fields - Add database indexes on scopeKey, updatedAt, and id for better query performance.

Last updated on