open-design/apps/daemon/db.ts

497 lines
14 KiB
TypeScript

// @ts-nocheck
// SQLite-backed persistence for projects, conversations, messages, and the
// per-project set of open file tabs. The on-disk project folder under
// .od/projects/<id>/ 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;
let dbFile = null;
export function openDatabase(projectRoot, { dataDir } = {}) {
const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od');
const file = path.join(dir, 'app.sqlite');
if (dbInstance && dbFile === file) return dbInstance;
if (dbInstance) closeDatabase();
fs.mkdirSync(dir, { recursive: true });
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
migrate(db);
dbInstance = db;
dbFile = file;
return db;
}
export function closeDatabase() {
if (!dbInstance) return;
dbInstance.close();
dbInstance = null;
dbFile = null;
}
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,
agent_id TEXT,
agent_name TEXT,
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`);
}
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all();
if (!messageCols.some((c) => c.name === 'agent_id')) {
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
}
if (!messageCols.some((c) => c.name === 'agent_name')) {
db.exec(`ALTER TABLE messages ADD COLUMN agent_name 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, agent_id AS agentId, agent_name AS agentName,
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 = ?, agent_id = ?, agent_name = ?,
events_json = ?, attachments_json = ?,
produced_files_json = ?, started_at = ?, ended_at = ?
WHERE id = ?`,
).run(
m.role,
m.content,
m.agentId ?? null,
m.agentName ?? null,
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;
// 13 values: id, conversation_id, role, content, agent_id, agent_name,
// events_json, attachments_json, produced_files_json, started_at,
// ended_at, position, created_at.
db.prepare(
`INSERT INTO messages
(id, conversation_id, role, content, agent_id, agent_name, events_json,
attachments_json, produced_files_json,
started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
m.id,
conversationId,
m.role,
m.content,
m.agentId ?? null,
m.agentName ?? null,
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, agent_id AS agentId, agent_name AS agentName,
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,
agentId: row.agentId ?? undefined,
agentName: row.agentName ?? undefined,
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);
}