// SQLite-backed persistence for projects, conversations, messages, and the // per-project set of open file tabs. The on-disk project folder under // .od/projects// is still the single owner of the user's actual files // (HTML artifacts, sketches, uploads); this database tracks the metadata // that used to live in localStorage. import Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; let dbInstance = null; export function openDatabase(projectRoot) { if (dbInstance) return dbInstance; const dir = path.join(projectRoot, '.od'); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, 'app.sqlite'); const db = new Database(file); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); migrate(db); dbInstance = db; return db; } function migrate(db) { db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, skill_id TEXT, design_system_id TEXT, pending_prompt TEXT, metadata_json TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, source_project_id TEXT, files_json TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_conv_project ON conversations(project_id, updated_at DESC); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, events_json TEXT, attachments_json TEXT, produced_files_json TEXT, started_at INTEGER, ended_at INTEGER, position INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, position); CREATE TABLE IF NOT EXISTS tabs ( project_id TEXT NOT NULL, name TEXT NOT NULL, position INTEGER NOT NULL, is_active INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(project_id, name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_tabs_project ON tabs(project_id, position); `); // Forward-compatible column add for databases created before metadata_json. // SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info. const cols = db.prepare(`PRAGMA table_info(projects)`).all(); if (!cols.some((c) => c.name === 'metadata_json')) { db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`); } } // ---------- projects ---------- const PROJECT_COLS = `id, name, skill_id AS skillId, design_system_id AS designSystemId, pending_prompt AS pendingPrompt, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt`; export function listProjects(db) { const rows = db .prepare( `SELECT ${PROJECT_COLS} FROM projects ORDER BY updated_at DESC`, ) .all(); return rows.map(normalizeProject); } export function getProject(db, id) { const row = db .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`) .get(id); return row ? normalizeProject(row) : null; } export function insertProject(db, p) { db.prepare( `INSERT INTO projects (id, name, skill_id, design_system_id, pending_prompt, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( p.id, p.name, p.skillId ?? null, p.designSystemId ?? null, p.pendingPrompt ?? null, p.metadata ? JSON.stringify(p.metadata) : null, p.createdAt, p.updatedAt, ); return getProject(db, p.id); } export function updateProject(db, id, patch) { const existing = getProject(db, id); if (!existing) return null; const merged = { ...existing, ...patch, updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), }; db.prepare( `UPDATE projects SET name = ?, skill_id = ?, design_system_id = ?, pending_prompt = ?, metadata_json = ?, updated_at = ? WHERE id = ?`, ).run( merged.name, merged.skillId ?? null, merged.designSystemId ?? null, merged.pendingPrompt ?? null, merged.metadata ? JSON.stringify(merged.metadata) : null, merged.updatedAt, id, ); return getProject(db, id); } export function deleteProject(db, id) { db.prepare(`DELETE FROM projects WHERE id = ?`).run(id); } function normalizeProject(row) { let metadata; if (row.metadataJson) { try { metadata = JSON.parse(row.metadataJson); } catch { metadata = undefined; } } return { id: row.id, name: row.name, skillId: row.skillId, designSystemId: row.designSystemId, pendingPrompt: row.pendingPrompt ?? undefined, metadata, createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } // ---------- templates ---------- export function listTemplates(db) { return db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates ORDER BY created_at DESC`, ) .all() .map(normalizeTemplate); } export function getTemplate(db, id) { const row = db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates WHERE id = ?`, ) .get(id); return row ? normalizeTemplate(row) : null; } export function insertTemplate(db, t) { db.prepare( `INSERT INTO templates (id, name, description, source_project_id, files_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, ).run( t.id, t.name, t.description ?? null, t.sourceProjectId ?? null, JSON.stringify(t.files ?? []), t.createdAt, ); return getTemplate(db, t.id); } export function deleteTemplate(db, id) { db.prepare(`DELETE FROM templates WHERE id = ?`).run(id); } function normalizeTemplate(row) { let files = []; try { files = JSON.parse(row.filesJson || '[]'); } catch { files = []; } return { id: row.id, name: row.name, description: row.description ?? undefined, sourceProjectId: row.sourceProjectId ?? undefined, files, createdAt: Number(row.createdAt), }; } // ---------- conversations ---------- export function listConversations(db, projectId) { return db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE project_id = ? ORDER BY updated_at DESC`, ) .all(projectId) .map((r) => ({ id: r.id, projectId: r.projectId, title: r.title ?? null, createdAt: Number(r.createdAt), updatedAt: Number(r.updatedAt), })); } export function getConversation(db, id) { const r = db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE id = ?`, ) .get(id); if (!r) return null; return { id: r.id, projectId: r.projectId, title: r.title ?? null, createdAt: Number(r.createdAt), updatedAt: Number(r.updatedAt), }; } export function insertConversation(db, c) { db.prepare( `INSERT INTO conversations (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, ).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt); return getConversation(db, c.id); } export function updateConversation(db, id, patch) { const existing = getConversation(db, id); if (!existing) return null; const merged = { ...existing, ...patch, updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), }; db.prepare( `UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?`, ).run(merged.title ?? null, merged.updatedAt, id); return getConversation(db, id); } export function deleteConversation(db, id) { db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id); } // ---------- messages ---------- export function listMessages(db, conversationId) { return db .prepare( `SELECT id, role, content, events_json AS eventsJson, attachments_json AS attachmentsJson, produced_files_json AS producedFilesJson, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE conversation_id = ? ORDER BY position ASC`, ) .all(conversationId) .map(normalizeMessage); } export function upsertMessage(db, conversationId, m) { const existing = db .prepare(`SELECT position FROM messages WHERE id = ?`) .get(m.id); const now = Date.now(); if (existing) { db.prepare( `UPDATE messages SET role = ?, content = ?, events_json = ?, attachments_json = ?, produced_files_json = ?, started_at = ?, ended_at = ? WHERE id = ?`, ).run( m.role, m.content, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.startedAt ?? null, m.endedAt ?? null, m.id, ); } else { const max = db .prepare( `SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`, ) .get(conversationId); const position = (max?.m ?? -1) + 1; db.prepare( `INSERT INTO messages (id, conversation_id, role, content, events_json, attachments_json, produced_files_json, started_at, ended_at, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( m.id, conversationId, m.role, m.content, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.startedAt ?? null, m.endedAt ?? null, position, now, ); } // Bump conversation activity so the sidebar's recency sort works. db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`).run( now, conversationId, ); const row = db .prepare( `SELECT id, role, content, events_json AS eventsJson, attachments_json AS attachmentsJson, produced_files_json AS producedFilesJson, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE id = ?`, ) .get(m.id); return row ? normalizeMessage(row) : null; } export function deleteMessage(db, id) { db.prepare(`DELETE FROM messages WHERE id = ?`).run(id); } function normalizeMessage(row) { return { id: row.id, role: row.role, content: row.content, events: parseJsonOrUndef(row.eventsJson), attachments: parseJsonOrUndef(row.attachmentsJson), producedFiles: parseJsonOrUndef(row.producedFilesJson), startedAt: row.startedAt ?? undefined, endedAt: row.endedAt ?? undefined, }; } function parseJsonOrUndef(s) { if (!s) return undefined; try { return JSON.parse(s); } catch { return undefined; } } // ---------- tabs ---------- export function listTabs(db, projectId) { const rows = db .prepare( `SELECT name, position, is_active AS isActive FROM tabs WHERE project_id = ? ORDER BY position ASC`, ) .all(projectId); const active = rows.find((r) => r.isActive) ?? null; return { tabs: rows.map((r) => r.name), active: active ? active.name : null, }; } export function setTabs(db, projectId, names, activeName) { const tx = db.transaction(() => { db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId); const ins = db.prepare( `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, ?)`, ); names.forEach((name, i) => { ins.run(projectId, name, i, name === activeName ? 1 : 0); }); }); tx(); return listTabs(db, projectId); }