/**
* Supabase Conversation Store
*
* Task 3.1 (Cloud Migration): Persistent Conversation State Store
* Constraint Removed: Local SQLite file → Cloud-hosted Supabase PostgreSQL
*
* Witness Outcome: Conversation state visible on Supabase dashboard in real-time
*/
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import type { ConversationState } from './conversation-migration.js';
export interface SupabaseConversationStoreConfig {
supabaseUrl?: string;
supabaseKey?: string;
}
/**
* Supabase Conversation Store
*
* Provides cloud-hosted PostgreSQL persistence for conversation state via Supabase.
* Ensures WHO/WHAT/HOW dimensions survive server restarts and are visible on dashboard.
*/
export class SupabaseConversationStore {
private supabase: SupabaseClient;
constructor(config: SupabaseConversationStoreConfig = {}) {
const supabaseUrl = config.supabaseUrl || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = config.supabaseKey || process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
throw new Error(
'Supabase credentials missing. Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables.'
);
}
this.supabase = createClient(supabaseUrl, supabaseKey);
console.error(`[SupabaseConversationStore] Initialized: ${supabaseUrl}`);
}
/**
* Get conversation state by ID
*
* Returns null if conversation doesn't exist.
*/
async getConversation(conversationId: string): Promise<ConversationState | null> {
const { data, error } = await this.supabase
.from('conversations')
.select('*')
.eq('conversation_id', conversationId)
.single();
if (error) {
if (error.code === 'PGRST116') {
// No rows returned - conversation doesn't exist
return null;
}
console.error(`[SupabaseConversationStore] Get error:`, error);
throw new Error(`Failed to get conversation: ${error.message}`);
}
if (!data) {
return null;
}
return this.deserialize(data);
}
/**
* Save conversation state
*
* Creates new conversation or updates existing one using upsert.
*/
async saveConversation(state: ConversationState): Promise<void> {
const { error } = await this.supabase
.from('conversations')
.upsert({
conversation_id: state.conversationId,
identity: state.identity,
intent_history: state.intentHistory,
permissions: state.permissionHistory,
current_level: state.currentLevel, // M4: Permission graduation
updated_at: new Date().toISOString(),
}, {
onConflict: 'conversation_id'
});
if (error) {
console.error(`[SupabaseConversationStore] Save error:`, error);
throw new Error(`Failed to save conversation: ${error.message}`);
}
console.error(`[SupabaseConversationStore] Saved: ${state.conversationId}`);
}
/**
* Delete conversation
*/
async deleteConversation(conversationId: string): Promise<void> {
const { error } = await this.supabase
.from('conversations')
.delete()
.eq('conversation_id', conversationId);
if (error) {
console.error(`[SupabaseConversationStore] Delete error:`, error);
throw new Error(`Failed to delete conversation: ${error.message}`);
}
console.error(`[SupabaseConversationStore] Deleted: ${conversationId}`);
}
/**
* List all conversation IDs
*/
async listConversations(): Promise<string[]> {
const { data, error } = await this.supabase
.from('conversations')
.select('conversation_id')
.order('updated_at', { ascending: false });
if (error) {
console.error(`[SupabaseConversationStore] List error:`, error);
throw new Error(`Failed to list conversations: ${error.message}`);
}
return data.map(row => row.conversation_id);
}
/**
* Deserialize database row to ConversationState
*/
private deserialize(row: any): ConversationState {
return {
conversationId: row.conversation_id,
identity: row.identity,
intentHistory: row.intent_history,
permissionHistory: row.permissions,
currentLevel: row.current_level ?? 1, // M4: Default to level 1 for backward compatibility
};
}
/**
* Get statistics (for debugging)
*/
async getStats(): Promise<{ totalConversations: number; oldestConversation: string | null; newestConversation: string | null }> {
const { count, error: countError } = await this.supabase
.from('conversations')
.select('*', { count: 'exact', head: true });
if (countError) {
console.error(`[SupabaseConversationStore] Stats error:`, countError);
return { totalConversations: 0, oldestConversation: null, newestConversation: null };
}
const { data: oldest } = await this.supabase
.from('conversations')
.select('conversation_id')
.order('created_at', { ascending: true })
.limit(1)
.single();
const { data: newest } = await this.supabase
.from('conversations')
.select('conversation_id')
.order('created_at', { ascending: false })
.limit(1)
.single();
return {
totalConversations: count || 0,
oldestConversation: oldest?.conversation_id || null,
newestConversation: newest?.conversation_id || null,
};
}
}