import { getArchiveOpfsDbFiles, getArchiveOpfsPrimaryDbName, isOpfsEnabled } from './storage.js';
let sqlite3 = null;
let db = null;
let isInitialized = false;
export async function initDatabase(dbBytes) {
if (isInitialized) {
console.warn('[DB] Already initialized');
return;
}
console.log('[DB] Initializing sqlite-wasm...');
sqlite3 = await loadSqliteWasm();
if (isOpfsEnabled() && sqlite3.oo1.OpfsDb && navigator.storage?.getDirectory) {
try {
const opfsDbName = getArchiveOpfsPrimaryDbName();
await writeBytesToOPFS(dbBytes);
db = new sqlite3.oo1.OpfsDb(`/${opfsDbName}`);
console.log('[DB] Loaded from OPFS');
isInitialized = true;
return;
} catch (error) {
await cleanupArchiveOpfsDatabaseFiles();
console.warn('[DB] OPFS unavailable, using in-memory:', error.message);
}
}
db = new sqlite3.oo1.DB();
const ptr = sqlite3.wasm.allocFromTypedArray(dbBytes);
try {
db.deserialize(ptr, dbBytes.length);
console.log('[DB] Loaded into memory');
} finally {
sqlite3.wasm.dealloc(ptr);
}
isInitialized = true;
}
async function loadSqliteWasm() {
try {
const module = await import('./vendor/sqlite3.js');
return await module.default();
} catch (error) {
console.error('[DB] Failed to load sqlite-wasm:', error);
throw new Error('SQLite library not available. Ensure sqlite3.js is in the vendor folder.');
}
}
async function writeBytesToOPFS(bytes) {
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(getArchiveOpfsPrimaryDbName(), { create: true });
const writable = await handle.createWritable();
await writable.write(bytes);
await writable.close();
}
async function cleanupArchiveOpfsDatabaseFiles() {
try {
const root = await navigator.storage.getDirectory();
for (const name of getArchiveOpfsDbFiles()) {
try {
await root.removeEntry(name);
} catch (error) {
if (error?.name !== 'NotFoundError') {
console.warn('[DB] Failed to clean up OPFS database file:', name, error);
}
}
}
} catch (error) {
console.warn('[DB] Failed to clean up OPFS database directory:', error);
}
}
export function withQuery(sql, params = [], callback) {
if (!db) {
throw new Error('Database not initialized');
}
const stmt = db.prepare(sql);
try {
if (params.length > 0) {
stmt.bind(params);
}
return callback(stmt);
} finally {
stmt.free(); }
}
export function queryAll(sql, params = []) {
return withQuery(sql, params, (stmt) => {
const results = [];
while (stmt.step()) {
results.push(stmt.getAsObject());
}
return results;
});
}
export function queryOne(sql, params = []) {
return withQuery(sql, params, (stmt) => {
return stmt.step() ? stmt.getAsObject() : null;
});
}
export function queryValue(sql, params = []) {
return withQuery(sql, params, (stmt) => {
return stmt.step() ? stmt.get()[0] : null;
});
}
export function execute(sql, params = []) {
if (!db) {
throw new Error('Database not initialized');
}
db.exec(sql, { bind: params });
return db.changes();
}
export function getExportMeta() {
try {
const rows = queryAll('SELECT key, value FROM export_meta');
return Object.fromEntries(rows.map(r => [r.key, r.value]));
} catch {
return {};
}
}
export function getStatistics() {
return {
conversations: queryValue('SELECT COUNT(*) FROM conversations') || 0,
messages: queryValue('SELECT COUNT(*) FROM messages') || 0,
agents: queryAll('SELECT DISTINCT agent FROM conversations').map(r => r.agent),
workspaces: queryAll('SELECT DISTINCT workspace FROM conversations WHERE workspace IS NOT NULL').map(r => r.workspace),
};
}
export function getRecentConversations(limit = 50) {
return queryAll(`
SELECT id, agent, workspace, title, source_path, started_at, ended_at, message_count
FROM conversations
ORDER BY started_at DESC
LIMIT ?
`, [limit]);
}
export function getConversation(convId) {
return queryOne(`
SELECT id, agent, workspace, title, source_path, started_at, ended_at, message_count, metadata_json
FROM conversations
WHERE id = ?
`, [convId]);
}
export function getConversationMessages(convId) {
return queryAll(`
SELECT id, idx, role, content, created_at, updated_at, model
FROM messages
WHERE conversation_id = ?
ORDER BY idx ASC
`, [convId]);
}
function isCodeQuery(query) {
const hasCodeChars =
query.includes('_') ||
query.includes('.') ||
query.includes('/') ||
query.includes('\\') ||
query.includes('::') ||
query.includes('#') ||
query.includes('@') ||
query.includes('$') ||
query.includes('%');
const hasCamelCase = /[a-z][A-Z]/.test(query);
const hasKebabCase = /[a-zA-Z]-[a-zA-Z]/.test(query);
const isCode = hasCodeChars || hasCamelCase || hasKebabCase;
const words = query.trim().split(/\s+/);
const wordCount = words.length;
const lower = query.toLowerCase();
const hasProseIndicators =
wordCount > 3 ||
lower.startsWith('how ') ||
lower.startsWith('what ') ||
lower.startsWith('why ') ||
lower.startsWith('when ') ||
lower.startsWith('where ') ||
lower.includes(' the ') ||
lower.includes(' is ') ||
lower.includes(' are ') ||
lower.includes(' was ') ||
lower.includes(' were ');
if (isCode && !hasProseIndicators) {
return true;
}
if (hasProseIndicators && !isCode) {
return false;
}
if (isCode) {
return true;
}
return false;
}
function escapeFts5Query(query) {
return query
.split(/\s+/)
.filter(t => t.length > 0)
.map(t => `"${t.replace(/"/g, '""')}"`)
.join(' ');
}
function normalizeTimestampFilterValue(value) {
if (value === undefined || value === null || value === '') {
return null;
}
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0 || !Number.isSafeInteger(numeric)) {
return null;
}
return numeric;
}
export function searchConversations(query, options = {}) {
const { limit = 50, offset = 0, agent = null, searchMode = 'auto', since = null, until = null } = options;
const escapedQuery = escapeFts5Query(query);
if (!escapedQuery) {
return [];
}
let ftsTable;
if (searchMode === 'code') {
ftsTable = 'messages_code_fts';
} else if (searchMode === 'prose') {
ftsTable = 'messages_fts';
} else {
ftsTable = isCodeQuery(query) ? 'messages_code_fts' : 'messages_fts';
}
let sql = `
SELECT
m.conversation_id,
m.id as message_id,
m.role,
snippet(${ftsTable}, 0, '<mark>', '</mark>', '...', 32) as snippet,
c.agent,
c.workspace,
c.title,
c.started_at,
bm25(${ftsTable}) as score
FROM ${ftsTable}
JOIN messages m ON ${ftsTable}.rowid = m.id
JOIN conversations c ON m.conversation_id = c.id
WHERE ${ftsTable} MATCH ?
`;
const params = [escapedQuery];
if (agent) {
sql += ' AND c.agent = ?';
params.push(agent);
}
const sinceTimestamp = normalizeTimestampFilterValue(since);
if (sinceTimestamp !== null) {
sql += ' AND c.started_at >= ?';
params.push(sinceTimestamp);
}
const untilTimestamp = normalizeTimestampFilterValue(until);
if (untilTimestamp !== null) {
sql += ' AND c.started_at <= ?';
params.push(untilTimestamp);
}
sql += `
ORDER BY score
LIMIT ? OFFSET ?
`;
params.push(limit, offset);
try {
return queryAll(sql, params);
} catch (error) {
console.error('[DB] Search error:', error);
return [];
}
}
export function getConversationsByAgent(agent, limit = 50, since = null, until = null) {
let sql = `
SELECT id, agent, workspace, title, source_path, started_at, message_count
FROM conversations
WHERE agent = ?
`;
const params = [agent];
const sinceTimestamp = normalizeTimestampFilterValue(since);
if (sinceTimestamp !== null) {
sql += ' AND started_at >= ?';
params.push(sinceTimestamp);
}
const untilTimestamp = normalizeTimestampFilterValue(until);
if (untilTimestamp !== null) {
sql += ' AND started_at <= ?';
params.push(untilTimestamp);
}
sql += `
ORDER BY started_at DESC
LIMIT ?
`;
params.push(limit);
return queryAll(sql, params);
}
export function getConversationsByWorkspace(workspace, limit = 50) {
return queryAll(`
SELECT id, agent, workspace, title, source_path, started_at, message_count
FROM conversations
WHERE workspace = ?
ORDER BY started_at DESC
LIMIT ?
`, [workspace, limit]);
}
export function getConversationsByTimeRange(since, until, limit = 50) {
return queryAll(`
SELECT id, agent, workspace, title, source_path, started_at, message_count
FROM conversations
WHERE started_at >= ? AND started_at <= ?
ORDER BY started_at DESC
LIMIT ?
`, [since, until, limit]);
}
export function getMemoryUsage() {
if (!sqlite3?.wasm?.HEAPU8) {
return null;
}
const heap = sqlite3.wasm.HEAPU8;
const limit = 256 * 1024 * 1024;
return {
used: heap.length,
limit: limit,
percent: (heap.length / limit) * 100,
};
}
export function checkMemoryPressure() {
const usage = getMemoryUsage();
if (usage && usage.percent > 80) {
console.warn(`[DB] WASM memory at ${usage.percent.toFixed(1)}%`);
return true;
}
return false;
}
export function closeDatabase() {
if (db) {
try {
db.close();
console.log('[DB] Closed');
} catch (error) {
console.warn('[DB] Close failed, resetting handle anyway:', error);
} finally {
db = null;
isInitialized = false;
}
}
}
export function isDatabaseReady() {
return isInitialized;
}
export function detectSearchMode(query) {
return isCodeQuery(query) ? 'code' : 'prose';
}
export default {
initDatabase,
queryAll,
queryOne,
queryValue,
execute,
withQuery,
getExportMeta,
getStatistics,
getRecentConversations,
getConversation,
getConversationMessages,
searchConversations,
detectSearchMode,
getConversationsByAgent,
getConversationsByWorkspace,
getConversationsByTimeRange,
getMemoryUsage,
checkMemoryPressure,
closeDatabase,
isDatabaseReady,
};