Custom Storage Adapters
Custom Storage Adapters
Section titled “Custom Storage Adapters”Vitamem ships with two storage backends — EphemeralAdapter (in-memory, for development) and SupabaseAdapter (production). If your infrastructure uses a different database, you can implement the StorageAdapter interface to plug in any backend.
The StorageAdapter Interface
Section titled “The StorageAdapter Interface”The full interface is defined in src/types.ts:
interface StorageAdapter { // Thread management createThread(userId: string): Promise<Thread>; getThread(threadId: string): Promise<Thread | null>; updateThread(thread: Thread): Promise<Thread>;
// Message storage addMessage( threadId: string, role: Message["role"], content: string, ): Promise<Message>; getMessages(threadId: string): Promise<Message[]>;
// Memory persistence saveMemory(memory: Omit<Memory, "id" | "createdAt">): Promise<Memory>; getMemories(userId: string): Promise<Memory[]>; searchMemories( userId: string, embedding: number[], limit?: number, ): Promise<MemoryMatch[]>;
// Optional methods getThreadsByState?(state: ThreadState): Promise<Thread[]>; deleteMemory?(memoryId: string): Promise<void>; deleteUserMemories?(userId: string): Promise<void>;}There are 9 required methods and 3 optional ones. The optional methods enable sweepThreads(), deleteMemory(), and deleteUserData() on the Vitamem facade.
Method-by-Method Walkthrough
Section titled “Method-by-Method Walkthrough”Required Methods
Section titled “Required Methods”createThread(userId: string): Promise<Thread>
Section titled “createThread(userId: string): Promise<Thread>”Create a new thread in the active state. Generate a unique ID (UUID recommended) and set all timestamp fields:
createdAtandupdatedAtto the current timelastMessageAt,coolingStartedAt,dormantAt,closedAttonullmessagesto an empty arraystateto"active"
getThread(threadId: string): Promise<Thread | null>
Section titled “getThread(threadId: string): Promise<Thread | null>”Retrieve a thread by its ID. Return null if not found. The returned object must include all Thread fields with proper Date objects (not ISO strings).
updateThread(thread: Thread): Promise<Thread>
Section titled “updateThread(thread: Thread): Promise<Thread>”Persist changes to an existing thread. The state machine calls this after every transition (active -> cooling, cooling -> dormant, etc.) with updated state and timestamp fields. Replace the entire thread record.
addMessage(threadId, role, content): Promise<Message>
Section titled “addMessage(threadId, role, content): Promise<Message>”Append a message to a thread. Generate a unique ID and set createdAt. Messages must be retrievable in chronological order by getMessages().
getMessages(threadId: string): Promise<Message[]>
Section titled “getMessages(threadId: string): Promise<Message[]>”Return all messages for a thread, sorted by createdAt ascending. This is called during the embedding pipeline to get the full conversation for memory extraction.
saveMemory(memory): Promise<Memory>
Section titled “saveMemory(memory): Promise<Memory>”Persist an extracted memory. The input omits id and createdAt — you must generate both. The embedding field is a number[] (typically 1536 dimensions for OpenAI’s text-embedding-3-small). Your storage must preserve the full vector.
getMemories(userId: string): Promise<Memory[]>
Section titled “getMemories(userId: string): Promise<Memory[]>”Return all memories for a user. This is used by the deduplication pipeline to check for duplicate facts during dormant transitions.
searchMemories(userId, embedding, limit?): Promise<MemoryMatch[]>
Section titled “searchMemories(userId, embedding, limit?): Promise<MemoryMatch[]>”Semantic search: find the limit most similar memories to the given embedding vector. Return results sorted by descending similarity score (0 to 1).
For databases with native vector support (PostgreSQL + pgvector, Pinecone, etc.), use server-side similarity search. Otherwise, compute cosine similarity in application code:
import { cosineSimilarity } from "vitamem";
async searchMemories(userId, embedding, limit = 10) { const all = await this.getMemories(userId); return all .filter((m) => m.embedding !== null) .map((m) => ({ content: m.content, source: m.source, score: cosineSimilarity(embedding, m.embedding!), })) .sort((a, b) => b.score - a.score) .slice(0, limit);}Optional Methods
Section titled “Optional Methods”getThreadsByState?(state: ThreadState): Promise<Thread[]>
Section titled “getThreadsByState?(state: ThreadState): Promise<Thread[]>”Return all threads in a given state. Required for sweepThreads() to work. If not implemented, calling sweepThreads() on the facade will throw an error.
deleteMemory?(memoryId: string): Promise<void>
Section titled “deleteMemory?(memoryId: string): Promise<void>”Delete a single memory by ID. Required for the facade’s deleteMemory() method (GDPR single-fact deletion).
deleteUserMemories?(userId: string): Promise<void>
Section titled “deleteUserMemories?(userId: string): Promise<void>”Delete all memories for a user. Required for the facade’s deleteUserData() method (GDPR full erasure).
getProfile?(userId: string): Promise<UserProfile | null>
Section titled “getProfile?(userId: string): Promise<UserProfile | null>”Return the user’s structured profile, or null if no profile exists. Required to enable the hybrid memory architecture. When implemented, the retrieval pipeline will automatically inject profile data into conversation context and suppress stale memories that contradict profile values.
updateProfile?(userId: string, updates: Partial<Omit<UserProfile, "userId">>): Promise<void>
Section titled “updateProfile?(userId: string, updates: Partial<Omit<UserProfile, "userId">>): Promise<void>”Update the user’s profile with merge semantics. Creates the profile if it doesn’t exist. Array fields (conditions, allergies, medications, goals, emergencyContacts) should be replaced when present in updates. Object fields (vitals, customFields) should be shallow-merged.
updateProfileField?(userId: string, field: string, value: unknown, action: "set" | "add" | "remove"): Promise<void>
Section titled “updateProfileField?(userId: string, field: string, value: unknown, action: "set" | "add" | "remove"): Promise<void>”Update a single profile field with set/add/remove semantics. This is used by the structured extraction pipeline to apply classified facts to the profile:
set: Replace the field value entirelyadd: Append to an array field (deduplicating strings and medications by name)remove: Remove a value from an array field
Special handling required for vitals (add action receives { key, record }) and medications (add action receives a Medication object, deduplicate by name).
Complete SQLite Example
Section titled “Complete SQLite Example”Here is a skeleton implementation using better-sqlite3. Adapt it to your preferred SQLite library or any SQL database.
import { randomUUID } from "crypto";import Database from "better-sqlite3";import { cosineSimilarity } from "vitamem";import type { StorageAdapter, Thread, ThreadState, Message, Memory, MemoryMatch,} from "vitamem";
export class SQLiteAdapter implements StorageAdapter { private db: Database.Database;
constructor(dbPath: string) { this.db = new Database(dbPath); this.db.pragma("journal_mode = WAL"); this.migrate(); }
private migrate() { this.db.exec(` CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_message_at TEXT, cooling_started_at TEXT, dormant_at TEXT, closed_at TEXT );
CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id), role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL );
CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, thread_id TEXT NOT NULL REFERENCES threads(id), content TEXT NOT NULL, source TEXT NOT NULL, embedding TEXT, -- JSON-serialized number[] created_at TEXT NOT NULL ); `); }
async createThread(userId: string): Promise<Thread> { const now = new Date(); const id = randomUUID(); const thread: Thread = { id, userId, state: "active", messages: [], createdAt: now, updatedAt: now, lastMessageAt: null, coolingStartedAt: null, dormantAt: null, closedAt: null, };
this.db .prepare( `INSERT INTO threads (id, user_id, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, ) .run(id, userId, "active", now.toISOString(), now.toISOString());
return thread; }
async getThread(threadId: string): Promise<Thread | null> { const row = this.db .prepare("SELECT * FROM threads WHERE id = ?") .get(threadId) as Record<string, string> | undefined;
if (!row) return null; return this.mapThread(row); }
async getThreadsByState(state: ThreadState): Promise<Thread[]> { const rows = this.db .prepare("SELECT * FROM threads WHERE state = ?") .all(state) as Record<string, string>[];
return rows.map((row) => this.mapThread(row)); }
async updateThread(thread: Thread): Promise<Thread> { this.db .prepare( `UPDATE threads SET state = ?, updated_at = ?, last_message_at = ?, cooling_started_at = ?, dormant_at = ?, closed_at = ? WHERE id = ?`, ) .run( thread.state, thread.updatedAt.toISOString(), thread.lastMessageAt?.toISOString() ?? null, thread.coolingStartedAt?.toISOString() ?? null, thread.dormantAt?.toISOString() ?? null, thread.closedAt?.toISOString() ?? null, thread.id, );
return thread; }
async addMessage( threadId: string, role: Message["role"], content: string, ): Promise<Message> { const now = new Date(); const id = randomUUID(); const message: Message = { id, threadId, role, content, createdAt: now };
this.db .prepare( `INSERT INTO messages (id, thread_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, ) .run(id, threadId, role, content, now.toISOString());
return message; }
async getMessages(threadId: string): Promise<Message[]> { const rows = this.db .prepare( "SELECT * FROM messages WHERE thread_id = ? ORDER BY created_at ASC", ) .all(threadId) as Record<string, string>[];
return rows.map((row) => ({ id: row.id, threadId: row.thread_id, role: row.role as Message["role"], content: row.content, createdAt: new Date(row.created_at), })); }
async saveMemory(memory: Omit<Memory, "id" | "createdAt">): Promise<Memory> { const now = new Date(); const id = randomUUID(); const full: Memory = { ...memory, id, createdAt: now };
this.db .prepare( `INSERT INTO memories (id, user_id, thread_id, content, source, embedding, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( id, memory.userId, memory.threadId, memory.content, memory.source, memory.embedding ? JSON.stringify(memory.embedding) : null, now.toISOString(), );
return full; }
async getMemories(userId: string): Promise<Memory[]> { const rows = this.db .prepare("SELECT * FROM memories WHERE user_id = ?") .all(userId) as Record<string, string>[];
return rows.map((row) => ({ id: row.id, userId: row.user_id, threadId: row.thread_id, content: row.content, source: row.source as Memory["source"], embedding: row.embedding ? JSON.parse(row.embedding) : null, createdAt: new Date(row.created_at), })); }
async searchMemories( userId: string, embedding: number[], limit = 10, ): Promise<MemoryMatch[]> { // Client-side cosine similarity (no pgvector in SQLite) const all = await this.getMemories(userId); return all .filter((m) => m.embedding !== null) .map((m) => ({ content: m.content, source: m.source, score: cosineSimilarity(embedding, m.embedding!), })) .sort((a, b) => b.score - a.score) .slice(0, limit); }
async deleteMemory(memoryId: string): Promise<void> { this.db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId); }
async deleteUserMemories(userId: string): Promise<void> { this.db.prepare("DELETE FROM memories WHERE user_id = ?").run(userId); }
private mapThread(row: Record<string, string>): Thread { return { id: row.id, userId: row.user_id, state: row.state as ThreadState, messages: [], createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), lastMessageAt: row.last_message_at ? new Date(row.last_message_at) : null, coolingStartedAt: row.cooling_started_at ? new Date(row.cooling_started_at) : null, dormantAt: row.dormant_at ? new Date(row.dormant_at) : null, closedAt: row.closed_at ? new Date(row.closed_at) : null, }; }}Using Your Custom Adapter
Section titled “Using Your Custom Adapter”Pass it directly to createVitamem:
import { createVitamem } from "vitamem";import { SQLiteAdapter } from "./sqlite-adapter";
const mem = await createVitamem({ provider: "openai", apiKey: process.env.OPENAI_API_KEY, storage: new SQLiteAdapter("./vitamem.db"),});When storage is an object (not a string like "ephemeral" or "supabase"), Vitamem uses it directly without any resolution logic.
Checklist
Section titled “Checklist”Before shipping a custom adapter, verify:
-
createThreadgenerates unique IDs and setsstateto"active" -
getThreadreturnsnullfor missing threads (does not throw) -
updateThreadpersists all timestamp fields including nulls -
getMessagesreturns messages in chronological order -
saveMemorypreserves the full embedding vector without truncation -
searchMemoriesreturns results sorted by descending similarity score - Optional:
getThreadsByStateworks for all four states (active,cooling,dormant,closed) - Optional:
deleteMemoryanddeleteUserMemoriesfully remove data (GDPR compliance)