Files
openmaic/OpenMAIC/lib/utils/database.ts
2026-08-16 14:58:47 +08:00

1014 lines
37 KiB
TypeScript

import Dexie, { type EntityTable, type Table } from 'dexie';
import { DSL_VERSION } from '@openmaic/dsl';
import type {
Scene,
SceneType,
SceneContent,
Whiteboard,
VideoManifest,
GeneratedAgentConfig,
} from '@/lib/types/stage';
import type { Action } from '@/lib/types/action';
import type {
SessionType,
SessionStatus,
SessionConfig,
ToolCallRecord,
ToolCallRequest,
ChatSession,
} from '@/lib/types/chat';
import type { SceneOutline } from '@/lib/types/generation';
import type { VoiceDesign } from '@/lib/audio/voice-design';
import type { UIMessage } from 'ai';
import type { AgentEditSessionRecord } from '@/lib/agent/client/agent-edit-session-types';
import { createLogger } from '@/lib/logger';
import { beginStageRuntimeDeletionSafely, getRuntimeStore } from '@/lib/runtime/store';
import type { RuntimeStore } from '@openmaic/storage';
import {
withRuntimeStorageExclusiveLock,
withRuntimeStorageExclusiveLockUntilSettled,
withRuntimeStorageSharedLock,
} from './chat-storage-lock';
import type { ChatStorageOptions } from './chat-storage';
import type { AppDocument } from '@/lib/document-store';
import { BrowserKVStore } from '@openmaic/storage';
import { clearAssetPool } from '@/lib/media/asset-pool';
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
languageDirective?: string;
style?: string;
currentSceneId?: string;
agentIds?: string[]; // Agent IDs selected at creation time
videoManifest?: VideoManifest; // Generated video request manifest; non-indexed
interactiveMode?: boolean; // Interactive Mode flag; non-indexed
taskEngineMode?: boolean; // Vocational Task Engine flag; non-indexed
generatedAgentConfigs?: GeneratedAgentConfig[]; // Editor-authored agent roster snapshot
}
/**
* Folder table - User-created folders for grouping courses.
*
* Folder membership is device-local organization metadata, not part of the
* course document itself (which is owned by the `@openmaic/storage`
* DocumentStore in a separate database). It lives in this Dexie database
* alongside the legacy tables. See {@link StageFolderMembership}.
*/
export interface FolderRecord {
id: string; // Primary key
name: string;
order: number; // Sort order
createdAt: number; // timestamp
updatedAt: number; // timestamp
}
/**
* Stage→folder membership mapping. `stageId` is the primary key so each course
* has at most one row; a missing row (or `folderId === undefined`) means the
* course is unfiled. This is intentionally separate from both the legacy
* `stages` table (a migration mirror that nothing writes) and the
* DocumentStore stage row (version-independent document content), so folder
* grouping never touches document semantics.
*/
export interface StageFolderMembership {
stageId: string; // Primary key (FK -> DocumentStore stage id)
folderId?: string; // FK -> folders.id; undefined = unfiled
updatedAt: number; // timestamp
}
/**
* 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)
/** Stage ownership index. Absent on legacy rows; document walking remains their fallback. */
stageId?: string;
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 | ArrayBuffer; // 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[];
// True once generation finished for this stage. Gates resume-on-mount so an
// edited (e.g. slide-deleted) finished deck is not treated as "interrupted"
// and regenerated. Optional for backward compat with pre-existing records.
generationComplete?: boolean;
createdAt: number;
updatedAt: number;
}
/**
* MediaFile table - AI-generated media files (images/videos)
*/
export interface MediaFileRecord {
// Compound key: `${stageId}:${mediaRef}`. Successful and failed rows use
// the same reference space (allocated id after allocation, legacy ref before it).
id: string;
stageId: string; // FK → stages.id
/** Original gen_* reference retained after allocation for reload reconciliation. */
placeholderRef?: string;
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.
*
* LEGACY. The roster now persists on the stage document
* (`stage.generatedAgentConfigs`); this table is kept only as a lazy-migration
* source for classrooms whose roster (or voice fields) predate the
* document-embedded model. Production access is migration reads plus deletion
* hygiene: `deleteStageData` clears a deleted stage's rows (as does the
* deprecated `deleteStageWithRelatedData` cascade). Nothing writes new rows;
* do not add writers.
*/
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;
voiceDesign?: VoiceDesign; // 3-layer vocal descriptor for auto voice
createdAt: number;
}
/**
* VoiceProfile table - Browser-local TTS voice profiles
*/
export interface VoiceProfileRecord {
id: string;
providerId: string;
kind: 'prompt' | 'clone';
name: string;
voicePrompt?: string;
promptText?: string;
referenceAudio?: Blob;
referenceAudioName?: string;
referenceAudioMimeType?: string;
createdAt: number;
updatedAt: number;
}
/**
* Cached reference clip for a registered auto voice (any TTS provider). The
* clip is the source of truth; the deterministic `voiceId` is its key, enabling
* register-on-invalid re-registration after backend GC/restart.
*/
export interface AutoVoiceCacheRecord {
voiceId: string;
referenceAudio: Blob;
mimeType: string;
updatedAt: 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 = 17;
/**
* MAIC Database Instance
*/
class MAICDatabase extends Dexie {
// Table definitions
stages!: EntityTable<StageRecord, 'id'>;
scenes!: EntityTable<SceneRecord, 'id'>;
audioFiles!: EntityTable<AudioFileRecord, 'id'>;
imageFiles!: EntityTable<ImageFileRecord, 'id'>;
snapshots!: EntityTable<Snapshot, 'id'>; // Undo/redo snapshots (legacy)
chatSessions!: EntityTable<ChatSessionRecord, 'id'>;
chatRestoreStaging!: Table<ChatSessionRecord, [string, string]>;
playbackState!: EntityTable<PlaybackStateRecord, 'stageId'>;
stageOutlines!: EntityTable<StageOutlinesRecord, 'stageId'>;
mediaFiles!: EntityTable<MediaFileRecord, 'id'>;
generatedAgents!: EntityTable<GeneratedAgentRecord, 'id'>;
voiceProfiles!: EntityTable<VoiceProfileRecord, 'id'>;
autoVoiceCache!: EntityTable<AutoVoiceCacheRecord, 'voiceId'>;
agentEditSessions!: EntityTable<AgentEditSessionRecord, 'id'>;
folders!: EntityTable<FolderRecord, 'id'>;
stageFolders!: EntityTable<StageFolderMembership, 'stageId'>;
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',
});
// Version 9: Migrate legacy `language` field to `languageDirective`
// Old stages stored a BCP-47 locale code (e.g. "zh-CN"); new code expects a
// natural-language directive. Convert known locales and drop the old field.
const LOCALE_TO_DIRECTIVE: Record<string, string> = {
'zh-CN': 'Deliver the entire course in Chinese (Simplified, zh-CN).',
'en-US': 'Deliver the entire course in English (en-US).',
'ja-JP': 'Deliver the entire course in Japanese (ja-JP).',
'ru-RU': 'Deliver the entire course in Russian (ru-RU).',
};
this.version(9)
.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',
})
.upgrade(async (tx) => {
const table = tx.table('stages');
await table.toCollection().modify((stage: Record<string, unknown>) => {
const lang = stage.language as string | undefined;
if (lang && !stage.languageDirective) {
stage.languageDirective =
LOCALE_TO_DIRECTIVE[lang] || `Deliver the entire course in ${lang}.`;
}
delete stage.language;
});
});
// Version 10: Add browser-local voice profiles for serverless TTS voice storage.
this.version(10).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',
voiceProfiles: 'id, providerId, kind, updatedAt',
});
// Version 11: Add auto-voice reference-clip cache (provider-neutral register-by-id).
this.version(11).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',
voiceProfiles: 'id, providerId, kind, updatedAt',
autoVoiceCache: 'voiceId, updatedAt',
});
// Version 12: Add agentEditSessions — multi-session AI-editing conversation
// history per stage (replaces the single-thread localStorage store).
this.version(12).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',
voiceProfiles: 'id, providerId, kind, updatedAt',
autoVoiceCache: 'voiceId, updatedAt',
agentEditSessions: 'id, stageId, [stageId+updatedAt]',
});
// Version 13 briefly added chatStorageLocks on the draft chat cutover
// branch. Advance past it and remove the abandoned lease table so database
// versions stay monotonic for anyone who opened that intermediate build.
this.version(14).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',
voiceProfiles: 'id, providerId, kind, updatedAt',
autoVoiceCache: 'voiceId, updatedAt',
agentEditSessions: 'id, stageId, [stageId+updatedAt]',
chatStorageLocks: null,
});
// Version 15: backup restore staging must preserve chat IDs that are reused
// in different stage partitions; the legacy chat table is keyed by id only.
this.version(15).stores({
chatRestoreStaging: '[stageId+id], stageId, [stageId+createdAt]',
});
// Version 16: make newly-written audio independently reclaimable by stage.
// Legacy rows remain valid and are found through speech-action references.
this.version(16).stores({
audioFiles: 'id, stageId, createdAt',
});
// Version 17: Course folders — group courses into user-created folders.
// `folders` holds folder metadata; `stageFolders` maps each course (by
// DocumentStore stage id) to its folder. Neither touches the document
// aggregate: folder grouping is device-local organization metadata kept in
// this Dexie database alongside the legacy tables, so an existing course
// with no membership row is simply unfiled (no upgrade callback needed).
this.version(17).stores({
folders: 'id, order',
stageFolders: 'stageId, folderId',
});
}
}
// Create database instance
export const db = new MAICDatabase();
// ==================== Helper Functions ====================
/**
* Initialize database
* Call at application startup
*/
export async function initDatabase(): Promise<void> {
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(runtimeStore?: RuntimeStore): Promise<void> {
// Clear the whole runtime database first, including rows orphaned by an
// earlier best-effort stage deletion. This user-requested destructive action
// must fail loud: reporting success while runtime data remains is misleading.
await withRuntimeStorageExclusiveLock(async () => {
const { bumpGeneration } = await import('@/lib/document-store/storage-generation');
await bumpGeneration();
await (runtimeStore ?? getRuntimeStore()).deleteAllRuntime();
await deleteAllDocuments();
await clearDocumentStoreKeys();
await db.delete();
await clearAssetPool();
});
log.info('Database cleared');
}
/** Delete every aggregate without bypassing the document store's open connection. */
export async function deleteAllDocuments(): Promise<void> {
const { getDocumentStore } = await import('@/lib/document-store');
const store = getDocumentStore();
const documents = await store.listDocuments();
await Promise.all(documents.map((document) => store.deleteDocument(document.id)));
}
/** Remove device-local metadata owned by the document cutover. */
export async function clearDocumentStoreKeys(): Promise<void> {
if (typeof localStorage === 'undefined') return;
const kv = new BrowserKVStore();
for (const prefix of ['document-migration:', 'editor-current-scene:']) {
const keys = await kv.keys(prefix, 'device');
await Promise.all(keys.map((key) => kv.remove(key, 'device')));
}
}
function toChatSessionRecord(stageId: string, session: ChatSession): ChatSessionRecord {
return {
id: session.id,
stageId,
type: session.type,
title: session.title,
status: session.status,
messages: session.messages,
config: session.config,
toolCalls: session.toolCalls,
pendingToolCalls: session.pendingToolCalls,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
sceneId: session.sceneId,
lastActionIndex: session.lastActionIndex,
};
}
/**
* Export database contents (for backup)
*/
export async function exportDatabase(chatOptions: ChatStorageOptions = {}): Promise<{
documents: AppDocument[];
chatSessions: ChatSessionRecord[];
playbackState: PlaybackStateRecord[];
}> {
const {
accessDocument,
canonicalizeLegacyOutline,
canonicalizeLegacyScene,
canonicalizeLegacyStage,
getDocumentStore,
getLegacyDocumentStore,
} = await import('@/lib/document-store');
const documentStore = getDocumentStore();
// Backups must not strand courses that have not yet been opened since cutover.
// Route them through the normal lazy-migration seam before enumerating aggregates.
const legacyStages = await getLegacyDocumentStore().listStages();
await Promise.all(legacyStages.map((stage) => accessDocument(stage.id)));
const summaries = await documentStore.listDocuments();
const storedDocuments = (
await Promise.all(summaries.map((summary) => documentStore.loadDocument(summary.id)))
).filter((document): document is AppDocument => document !== null);
const storedIds = new Set(summaries.map((summary) => summary.id));
const legacyOnlyDocuments = (
await Promise.all(
legacyStages
.filter((stage) => !storedIds.has(stage.id))
.map(async (stage): Promise<AppDocument | null> => {
const snapshot = await getLegacyDocumentStore().read(stage.id);
if (!snapshot) return null;
const { stage: canonicalStage } = canonicalizeLegacyStage(snapshot.stage);
const document: AppDocument = {
stage: canonicalStage,
scenes: snapshot.scenes.map(canonicalizeLegacyScene).sort((a, b) => a.order - b.order),
dslVersion: DSL_VERSION,
};
if (snapshot.outline) document.outline = canonicalizeLegacyOutline(snapshot.outline);
return document;
}),
)
).filter((document): document is AppDocument => document !== null);
const documents = [...storedDocuments, ...legacyOnlyDocuments];
const legacyChatMap = new Map<string, ChatSessionRecord>();
for (const session of [
...(await db.chatSessions.toArray()),
...(await db.chatRestoreStaging.toArray()),
]) {
legacyChatMap.set(JSON.stringify([session.stageId, session.id]), session);
}
const legacyChats = [...legacyChatMap.values()];
const { loadChatSessions } = await import('./chat-storage');
// Chat sessions live on the learner RuntimeStore — an independent seam from
// document migration — so legacy-only documents can still own runtime chat
// history. Enumerate chats for EVERY exported document, not just stored ones.
// The legacy chat rows are collected separately above by direct table reads,
// so the RuntimeStore load gets an empty legacy source and never needs the
// cross-realm migration lock. It may still finalize a pending restore marker;
// `observe: false` only keeps this export from changing partition memos.
const runtimeChats = (
await Promise.all(
documents.map(async ({ stage }) =>
(
await loadChatSessions(stage.id, {
legacyStore: { load: async () => [], clear: async () => {} },
...chatOptions,
fallbackToLegacyOnError: false,
observe: false,
})
).map((session) => toChatSessionRecord(stage.id, session)),
),
)
).flat();
const runtimeChatKeys = new Set(
runtimeChats.map((session) => JSON.stringify([session.stageId, session.id])),
);
return {
documents,
chatSessions: [
...runtimeChats,
...legacyChats.filter(
(session) => !runtimeChatKeys.has(JSON.stringify([session.stageId, session.id])),
),
],
playbackState: await db.playbackState.toArray(),
};
}
/**
* Import database contents (for restoring backups)
*/
export async function importDatabase(
data: {
documents?: AppDocument[];
stages?: StageRecord[];
scenes?: SceneRecord[];
chatSessions?: ChatSessionRecord[];
playbackState?: PlaybackStateRecord[];
},
chatOptions: ChatStorageOptions = {},
): Promise<void> {
const { canonicalizeLegacyScene, canonicalizeLegacyStage, mutateDocument } =
await import('@/lib/document-store');
const legacyScenesByStage = new Map<string, SceneRecord[]>();
for (const scene of data.scenes ?? []) {
const scenes = legacyScenesByStage.get(scene.stageId) ?? [];
scenes.push(scene);
legacyScenesByStage.set(scene.stageId, scenes);
}
const documents =
data.documents ??
(data.stages ?? []).map((legacyStage): AppDocument => {
const { stage } = canonicalizeLegacyStage(legacyStage);
return {
stage,
scenes: (legacyScenesByStage.get(legacyStage.id) ?? [])
.map(canonicalizeLegacyScene)
.sort((a, b) => a.order - b.order),
};
});
const importedDocuments: Array<{
id: string;
preImage: AppDocument | null;
wasDeleted: boolean;
}> = [];
const importedCurrentScenes: Array<{ key: string; preImage: unknown | null }> = [];
const kv = new BrowserKVStore();
const { isStageDeleted, markStageDeleted, unmarkStageDeleted } = await import('./deleted-stages');
try {
for (const document of documents) {
// Record the pre-import deletion state alongside the document pre-image:
// a failed import rolls the document back, so it must roll this back too.
const wasDeleted = isStageDeleted(document.stage.id);
await mutateDocument(document.stage.id, async (_existing, store) => {
const preImage = (await store.loadDocument(document.stage.id)) as AppDocument | null;
await store.saveDocument(document);
importedDocuments.push({ id: document.stage.id, preImage, wasDeleted });
});
// Explicit document (re)creation: a backup may restore a stage deleted
// earlier this session under the same id. Lift the deleted flag so later
// edits of the restored document persist instead of being dropped. (The
// deletion epoch stays bumped, so pre-delete in-flight writes remain
// fenced off the restored document.)
unmarkStageDeleted(document.stage.id);
}
for (const legacyStage of data.stages ?? []) {
if (legacyStage.currentSceneId !== undefined) {
const key = `editor-current-scene:${legacyStage.id}`;
const preImage = await kv.get<unknown>(key, 'device');
await kv.set(
key,
{ sceneId: legacyStage.currentSceneId, updatedAt: new Date().toISOString() },
'device',
);
importedCurrentScenes.push({ key, preImage });
}
}
await withRuntimeStorageSharedLock(async () => {
const restoredChatStageIds =
data.chatSessions === undefined
? []
: [
...new Set([
...documents.map((document) => document.stage.id),
...data.chatSessions.map((session) => session.stageId),
]),
];
const restoreRows = () =>
db.transaction(
'rw',
[db.stages, db.scenes, db.chatSessions, db.chatRestoreStaging, db.playbackState],
async () => {
if (data.chatSessions) {
for (const stageId of restoredChatStageIds) {
await db.chatSessions.where('stageId').equals(stageId).delete();
await db.chatRestoreStaging.where('stageId').equals(stageId).delete();
}
await db.chatRestoreStaging.bulkPut(data.chatSessions);
}
if (data.playbackState) await db.playbackState.bulkPut(data.playbackState);
},
);
if (data.chatSessions !== undefined) {
// RuntimeStore cannot join Dexie's transaction. Keep a rollback image
// until durable restore markers have been created for every affected
// runtime partition; marker creation failure must not commit half an
// imported backup.
const rollbackImage = await db.transaction(
'r',
[db.stages, db.scenes, db.chatSessions, db.chatRestoreStaging, db.playbackState],
async () => ({
chatSessions: (
await Promise.all(
restoredChatStageIds.map((stageId) =>
db.chatSessions.where('stageId').equals(stageId).toArray(),
),
)
).flat(),
chatRestoreStaging: (
await Promise.all(
restoredChatStageIds.map((stageId) =>
db.chatRestoreStaging.where('stageId').equals(stageId).toArray(),
),
)
).flat(),
playbackState: (
await db.playbackState.bulkGet(
(data.playbackState ?? []).map((playback) => playback.stageId),
)
).filter((playback): playback is PlaybackStateRecord => playback !== undefined),
}),
);
const rollbackRows = () =>
db.transaction(
'rw',
[db.stages, db.scenes, db.chatSessions, db.chatRestoreStaging, db.playbackState],
async () => {
for (const stageId of restoredChatStageIds) {
await db.chatSessions.where('stageId').equals(stageId).delete();
await db.chatRestoreStaging.where('stageId').equals(stageId).delete();
}
await db.playbackState.bulkDelete(
(data.playbackState ?? []).map((playback) => playback.stageId),
);
await db.chatSessions.bulkPut(rollbackImage.chatSessions);
await db.chatRestoreStaging.bulkPut(rollbackImage.chatRestoreStaging);
await db.playbackState.bulkPut(rollbackImage.playbackState);
},
);
const { restoreChatSessionsFromBackup } = await import('./chat-storage');
await restoreChatSessionsFromBackup(restoredChatStageIds, restoreRows, {
...chatOptions,
globalLockHeld: true,
rollbackLegacyRows: rollbackRows,
});
} else {
await restoreRows();
}
log.info('Database imported successfully');
});
} catch (error) {
for (const { key, preImage } of importedCurrentScenes.reverse()) {
try {
if (preImage === null) await kv.remove(key, 'device');
else await kv.set(key, preImage, 'device');
} catch (rollbackError) {
log.error(`Failed to roll back imported current-scene key ${key}:`, rollbackError);
}
}
for (const { id, preImage, wasDeleted } of importedDocuments.reverse()) {
try {
await mutateDocument(id, async (_document, store) => {
if (preImage) await store.saveDocument(preImage);
else await store.deleteDocument(id);
});
// The rollback reinstated the pre-import world; reinstate the deletion
// state the import lifted, or the rolled-back (absent) document would
// stay writable and an outstanding flush could recreate it. Re-marking
// bumps the epoch again — consistent either way, since every pre-import
// capture is already stale. Deliberately skipped when the rollback
// write itself failed above: the imported document then still exists,
// and re-marking would silently drop edits to a document that is
// present (the exact bug the lift exists to prevent).
if (wasDeleted) markStageDeleted(id);
} catch (rollbackError) {
log.error(`Failed to roll back imported document ${id}:`, rollbackError);
}
}
throw error;
}
}
// ==================== Convenience Query Functions ====================
/**
* Get all scenes for a course
*/
export async function getScenesByStageId(stageId: string): Promise<Scene[]> {
const { accessDocument } = await import('@/lib/document-store');
return (await accessDocument(stageId)).document?.scenes ?? [];
}
/**
* Delete a course and all its related data
*
* @deprecated No production caller remains. `deleteStageData` is the primary
* stage-deletion path; this compatibility helper retains its broader author-side cascade.
*/
export async function deleteStageWithRelatedData(stageId: string): Promise<void> {
const { mutateDocument } = await import('@/lib/document-store');
// storageSharedLockHeld: the cascade holds the EXCLUSIVE epoch, which
// subsumes shared — the generation-guarded store must not re-acquire shared
// inside it (self-deadlock against our own exclusive hold).
await mutateDocument(
stageId,
async (document, store) =>
withRuntimeStorageExclusiveLockUntilSettled(async (releaseCaller) => {
const {
buildStageAssetReclamationPlan,
executeStageAssetReclamation,
loadStageAssetInventory,
} = await import('@/lib/media/reclaim-stage-assets');
const deletionDocument = document ?? {
stage: { id: stageId, name: '', createdAt: 0, updatedAt: 0 },
scenes: [],
};
const inventory = await loadStageAssetInventory(deletionDocument);
const assetPlan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
await store.deleteDocument(stageId);
await executeStageAssetReclamation(assetPlan, null);
await db.transaction(
'rw',
[
db.stages,
db.scenes,
db.chatSessions,
db.chatRestoreStaging,
db.playbackState,
db.stageOutlines,
db.generatedAgents,
db.agentEditSessions,
],
async () => {
await db.stages.delete(stageId);
await db.scenes.where('stageId').equals(stageId).delete();
await db.chatSessions.where('stageId').equals(stageId).delete();
await db.chatRestoreStaging.where('stageId').equals(stageId).delete();
await db.playbackState.delete(stageId);
await db.stageOutlines.delete(stageId);
await db.generatedAgents.where('stageId').equals(stageId).delete();
await db.agentEditSessions.where('stageId').equals(stageId).delete();
},
);
// Learner-runtime data lives in a separate IndexedDB database, so it is
// cascaded after the Dexie transaction: it cannot join it, and a runtime
// failure must not abort it (the helper warns instead of throwing).
const runtimeDeletion = beginStageRuntimeDeletionSafely(stageId);
await runtimeDeletion.completion;
releaseCaller(undefined);
await runtimeDeletion.settlement;
}),
{ storageSharedLockHeld: true },
);
}
/**
* Get all generated agents for a course
*/
export async function getGeneratedAgentsByStageId(
stageId: string,
): Promise<GeneratedAgentRecord[]> {
return db.generatedAgents.where('stageId').equals(stageId).toArray();
}
/**
* Get database statistics
*/
export async function getDatabaseStats() {
const { getDocumentStore } = await import('@/lib/document-store');
const documents = await getDocumentStore().listDocuments();
return {
documents: documents.length,
documentScenes: documents.reduce((total, document) => total + document.sceneCount, 0),
legacyStages: await db.stages.count(),
legacyScenes: 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(),
};
}