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 whereIndexedDBis 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
-
Always normalize messages - Use
normalizeMessagesMetadatabefore saving timelines to ensure consistent metadata:const { messages } = normalizeMessagesMetadata(timeline.messages); await persistence.saveTimeline({ ...timeline, messages }); -
Implement
loadSummariesefficiently - Return only the record fields needed to render thread lists. Avoid fetching full messages during summary loads. -
Return the full truth -
loadSummariesshould 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. -
Hydrate timelines -
loadTimelineshould surface the latest messages. The store eagerly calls it for the active thread afterloadSummaries, so provide canonical data. -
Handle errors gracefully - Wrap persistence calls in try-catch blocks to handle storage failures.
-
Use transactions - When using SQL databases, wrap multiple operations in transactions for consistency.
-
Index key fields - Add database indexes on
scopeKey,updatedAt, andidfor better query performance.
Related
- Message Normalization - Utility functions for normalizing message metadata
- ChatContainer - Main component that uses thread persistence
- useAIChat - Internal hook that manages thread state