import Dexie, { type EntityTable } from 'dexie'; import type { Scene, SceneType, SceneContent, Whiteboard } from '@/lib/types/stage'; import type { Action } from '@/lib/types/action'; import type { SessionType, SessionStatus, SessionConfig, ToolCallRecord, ToolCallRequest, } from '@/lib/types/chat'; import type { SceneOutline } from '@/lib/types/generation'; import type { UIMessage } from 'ai'; import { createLogger } from '@/lib/logger'; const log = createLogger('Database'); /** * Legacy Snapshot type for undo/redo functionality * Used by useSnapshotStore */ export interface Snapshot { id?: number; index: number; slides: Scene[]; } /** * MAIC Local Database * * Uses IndexedDB to store all user data locally * - Does not delete expired data; all data is stored permanently * - Uses a fixed database name * - Supports multi-course management */ // ==================== Database Table Type Definitions ==================== /** * Stage table - Course basic info */ export interface StageRecord { id: string; // Primary key name: string; description?: string; createdAt: number; // timestamp updatedAt: number; // timestamp language?: string; style?: string; currentSceneId?: string; } /** * Scene table - Scene/page data */ export interface SceneRecord { id: string; // Primary key stageId: string; // Foreign key -> stages.id type: SceneType; title: string; order: number; // Display order content: SceneContent; // Stored as JSON actions?: Action[]; // Stored as JSON whiteboard?: Whiteboard[]; // Stored as JSON createdAt: number; updatedAt: number; } /** * AudioFile table - Audio files (TTS) */ export interface AudioFileRecord { id: string; // Primary key (audioId) blob: Blob; // Audio binary data duration?: number; // Duration (seconds) format: string; // mp3, wav, etc. text?: string; // Corresponding text content voice?: string; // Voice used createdAt: number; ossKey?: string; // Full CDN URL for this audio blob } /** * ImageFile table - Image files */ export interface ImageFileRecord { id: string; // Primary key blob: Blob; // Image binary data filename: string; // Original filename mimeType: string; // image/png, image/jpeg, etc. size: number; // File size (bytes) createdAt: number; } /** * ChatSession table - Chat session data */ export interface ChatSessionRecord { id: string; // PK (session id) stageId: string; // FK -> stages.id type: SessionType; title: string; status: SessionStatus; messages: UIMessage[]; // JSON-safe serialized messages config: SessionConfig; toolCalls: ToolCallRecord[]; pendingToolCalls: ToolCallRequest[]; createdAt: number; updatedAt: number; sceneId?: string; lastActionIndex?: number; } /** * PlaybackState table - Playback state snapshot (at most one per stage) */ export interface PlaybackStateRecord { stageId: string; // PK sceneIndex: number; actionIndex: number; consumedDiscussions: string[]; updatedAt: number; } /** * StageOutlines table - Persisted outlines for resume-on-refresh */ export interface StageOutlinesRecord { stageId: string; // Primary key (FK -> stages.id) outlines: SceneOutline[]; createdAt: number; updatedAt: number; } /** * MediaFile table - AI-generated media files (images/videos) */ export interface MediaFileRecord { id: string; // Compound key: `${stageId}:${elementId}` stageId: string; // FK → stages.id type: 'image' | 'video'; blob: Blob; // Media binary mimeType: string; // image/png, video/mp4 size: number; poster?: Blob; // Video thumbnail blob prompt: string; // Original prompt (for retry) params: string; // JSON-serialized generation params error?: string; // If set, this is a failed task (blob is empty placeholder) errorCode?: string; // Structured error code (e.g. 'CONTENT_SENSITIVE') ossKey?: string; // Full CDN URL for this media blob posterOssKey?: string; // Full CDN URL for the poster blob createdAt: number; } /** * GeneratedAgent table - AI-generated agent profiles */ export interface GeneratedAgentRecord { id: string; // PK: agent ID (e.g. "gen-abc123") stageId: string; // FK -> stages.id name: string; role: string; // 'teacher' | 'assistant' | 'student' persona: string; avatar: string; color: string; priority: number; createdAt: number; } /** Build the compound primary key for mediaFiles: `${stageId}:${elementId}` */ export function mediaFileKey(stageId: string, elementId: string): string { return `${stageId}:${elementId}`; } // ==================== Database Definition ==================== const DATABASE_NAME = 'MAIC-Database'; const _DATABASE_VERSION = 8; /** * MAIC Database Instance */ class MAICDatabase extends Dexie { // Table definitions stages!: EntityTable; scenes!: EntityTable; audioFiles!: EntityTable; imageFiles!: EntityTable; snapshots!: EntityTable; // Undo/redo snapshots (legacy) chatSessions!: EntityTable; playbackState!: EntityTable; stageOutlines!: EntityTable; mediaFiles!: EntityTable; generatedAgents!: EntityTable; constructor() { super(DATABASE_NAME); // Version 1: Initial schema this.version(1).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', // Previously had: messages, participants, discussions, sceneSnapshots }); // Version 2: Remove unused tables this.version(2).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', // Delete removed tables messages: null, participants: null, discussions: null, sceneSnapshots: null, }); // Version 3: Add chatSessions and playbackState tables this.version(3).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', }); // Version 4: Add stageOutlines table for resume-on-refresh this.version(4).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', stageOutlines: 'stageId', }); // Version 5: Add mediaFiles table for async media generation this.version(5).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', stageOutlines: 'stageId', mediaFiles: 'id, stageId, [stageId+type]', }); // Version 6: Fix mediaFiles primary key — use compound key stageId:elementId // to prevent cross-course collisions (gen_img_1 is NOT globally unique) this.version(6) .stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', stageOutlines: 'stageId', mediaFiles: 'id, stageId, [stageId+type]', }) .upgrade(async (tx) => { const table = tx.table('mediaFiles'); const allRecords = await table.toArray(); for (const rec of allRecords) { const newKey = `${rec.stageId}:${rec.id}`; // Skip if already migrated (idempotent) if (rec.id.includes(':')) continue; await table.delete(rec.id); await table.put({ ...rec, id: newKey }); } }); // Version 7: Add ossKey fields to mediaFiles and audioFiles for OSS storage plugin // Non-indexed optional fields — Dexie handles these transparently. this.version(7).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', stageOutlines: 'stageId', mediaFiles: 'id, stageId, [stageId+type]', }); // Version 8: Add generatedAgents table for AI-generated agent profiles this.version(8).stores({ stages: 'id, updatedAt', scenes: 'id, stageId, order, [stageId+order]', audioFiles: 'id, createdAt', imageFiles: 'id, createdAt', snapshots: '++id', chatSessions: 'id, stageId, [stageId+createdAt]', playbackState: 'stageId', stageOutlines: 'stageId', mediaFiles: 'id, stageId, [stageId+type]', generatedAgents: 'id, stageId', }); } } // Create database instance export const db = new MAICDatabase(); // ==================== Helper Functions ==================== /** * Initialize database * Call at application startup */ export async function initDatabase(): Promise { try { await db.open(); // Request persistent storage to prevent browser from evicting IndexedDB // under storage pressure (large media blobs can trigger LRU cleanup) void navigator.storage?.persist?.(); log.info('Database initialized successfully'); } catch (error) { log.error('Failed to initialize database:', error); throw error; } } /** * Clear database (optional) * Use with caution: deletes all data */ export async function clearDatabase(): Promise { await db.delete(); log.info('Database cleared'); } /** * Export database contents (for backup) */ export async function exportDatabase(): Promise<{ stages: StageRecord[]; scenes: SceneRecord[]; chatSessions: ChatSessionRecord[]; playbackState: PlaybackStateRecord[]; }> { return { stages: await db.stages.toArray(), scenes: await db.scenes.toArray(), chatSessions: await db.chatSessions.toArray(), playbackState: await db.playbackState.toArray(), }; } /** * Import database contents (for restoring backups) */ export async function importDatabase(data: { stages?: StageRecord[]; scenes?: SceneRecord[]; chatSessions?: ChatSessionRecord[]; playbackState?: PlaybackStateRecord[]; }): Promise { await db.transaction( 'rw', [db.stages, db.scenes, db.chatSessions, db.playbackState], async () => { if (data.stages) await db.stages.bulkPut(data.stages); if (data.scenes) await db.scenes.bulkPut(data.scenes); if (data.chatSessions) await db.chatSessions.bulkPut(data.chatSessions); if (data.playbackState) await db.playbackState.bulkPut(data.playbackState); }, ); log.info('Database imported successfully'); } // ==================== Convenience Query Functions ==================== /** * Get all scenes for a course */ export async function getScenesByStageId(stageId: string): Promise { return db.scenes.where('stageId').equals(stageId).sortBy('order'); } /** * Delete a course and all its related data */ export async function deleteStageWithRelatedData(stageId: string): Promise { await db.transaction( 'rw', [ db.stages, db.scenes, db.chatSessions, db.playbackState, db.stageOutlines, db.mediaFiles, db.generatedAgents, ], async () => { await db.stages.delete(stageId); await db.scenes.where('stageId').equals(stageId).delete(); await db.chatSessions.where('stageId').equals(stageId).delete(); await db.playbackState.delete(stageId); await db.stageOutlines.delete(stageId); await db.mediaFiles.where('stageId').equals(stageId).delete(); await db.generatedAgents.where('stageId').equals(stageId).delete(); }, ); } /** * Get all generated agents for a course */ export async function getGeneratedAgentsByStageId( stageId: string, ): Promise { return db.generatedAgents.where('stageId').equals(stageId).toArray(); } /** * Get database statistics */ export async function getDatabaseStats() { return { stages: await db.stages.count(), scenes: await db.scenes.count(), audioFiles: await db.audioFiles.count(), imageFiles: await db.imageFiles.count(), snapshots: await db.snapshots.count(), chatSessions: await db.chatSessions.count(), playbackState: await db.playbackState.count(), stageOutlines: await db.stageOutlines.count(), mediaFiles: await db.mediaFiles.count(), generatedAgents: await db.generatedAgents.count(), }; }