import { getConnection } from '@/modules/database/connection.js'; import { projectsDb } from '@/modules/database/repositories/projects.db.js'; import { normalizeProjectPath } from '@/shared/utils.js'; type SessionRow = { session_id: string; provider: string; provider_session_id: string | null; project_path: string | null; jsonl_path: string | null; custom_name: string | null; isArchived: number; created_at: string; updated_at: string; }; const SESSION_ROW_COLUMNS = 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; function normalizeTimestamp(value?: string): string | null { if (!value) return null; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return null; } return parsed.toISOString(); } function normalizeProjectPathForProvider(provider: string, projectPath: string): string { void provider; return normalizeProjectPath(projectPath); } export const sessionsDb = { /** * Upserts one session row discovered on disk by a provider synchronizer. * * The given id is the provider-native session id. Rows are keyed by * `provider_session_id` so a session that was first created by the app * (with an app-allocated `session_id`) is updated in place once its * transcript shows up on disk, instead of producing a duplicate row. */ createSession( providerSessionId: string, provider: string, projectPath: string, customName?: string, createdAt?: string, updatedAt?: string, jsonlPath?: string | null ): string { const db = getConnection(); const createdAtValue = normalizeTimestamp(createdAt); const updatedAtValue = normalizeTimestamp(updatedAt); const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); // First, ensure the project path is recorded in the projects table, // since it's a foreign key in the sessions table. projectsDb.createProjectPath(normalizedProjectPath); const existing = db .prepare( `SELECT session_id FROM sessions WHERE provider_session_id = ? AND provider = ? LIMIT 1` ) .get(providerSessionId, provider) as { session_id: string } | undefined; if (existing) { db.prepare( `UPDATE sessions SET provider = ?, updated_at = COALESCE(?, CURRENT_TIMESTAMP), project_path = ?, jsonl_path = ?, isArchived = 0, custom_name = COALESCE(?, custom_name) WHERE session_id = ?` ).run( provider, updatedAtValue, normalizedProjectPath, jsonlPath ?? null, customName ?? null, existing.session_id ); return existing.session_id; } // Sessions created outside the app (directly via the provider CLI) are // keyed by the provider-native id for both columns. The ON CONFLICT path // covers legacy rows that predate the provider_session_id mapping. db.prepare( `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, provider_session_id = excluded.provider_session_id, updated_at = excluded.updated_at, project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, isArchived = 0, custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` ).run( providerSessionId, provider, providerSessionId, customName ?? null, normalizedProjectPath, jsonlPath ?? null, createdAtValue, updatedAtValue ); return providerSessionId; }, /** * Inserts one app-allocated session row before any provider run happens. * * The session gateway uses this when the frontend starts a brand-new chat: * `session_id` is the stable app-facing id, while `provider_session_id` * stays NULL until the provider runtime announces its own id and * `assignProviderSessionId` records the mapping. */ createAppSession(sessionId: string, provider: string, projectPath: string): string { const db = getConnection(); const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); projectsDb.createProjectPath(normalizedProjectPath); db.prepare( `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` ).run(sessionId, provider, normalizedProjectPath); return sessionId; }, /** * Records the provider-native session id for one app-allocated session. * * If the filesystem watcher indexed the provider transcript before this * mapping was recorded (a duplicate row keyed by the provider id exists), * the duplicate is merged into the app row: its transcript path and name * are adopted and the duplicate row is removed. Runs in a transaction so * the sidebar can never observe both rows at once. */ assignProviderSessionId(sessionId: string, providerSessionId: string): void { const db = getConnection(); const merge = db.transaction(() => { const duplicate = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE (session_id = ? OR provider_session_id = ?) AND session_id <> ? LIMIT 1` ) .get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined; if (duplicate) { db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id); db.prepare( `UPDATE sessions SET provider_session_id = ?, jsonl_path = COALESCE(jsonl_path, ?), custom_name = COALESCE(custom_name, ?), updated_at = CURRENT_TIMESTAMP WHERE session_id = ?` ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId); return; } db.prepare( `UPDATE sessions SET provider_session_id = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?` ).run(providerSessionId, sessionId); }); merge(); }, updateSessionCustomName(sessionId: string, customName: string): void { const db = getConnection(); db.prepare( `UPDATE sessions SET custom_name = ? WHERE session_id = ?` ).run(customName, sessionId); }, getSessionById(sessionId: string): SessionRow | null { const db = getConnection(); const row = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1` ) .get(sessionId) as SessionRow | undefined; return row ?? null; }, /** * Resolves one session row through the provider-native id. * * The filesystem watcher only knows provider ids (they come from transcript * file names), so it uses this lookup to translate disk artifacts back to * the app-facing session row before broadcasting sidebar updates. */ getSessionByProviderSessionId(providerSessionId: string): SessionRow | null { const db = getConnection(); const row = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE provider_session_id = ? ORDER BY updated_at DESC LIMIT 1` ) .get(providerSessionId) as SessionRow | undefined; return row ?? null; }, getAllSessions(): SessionRow[] { const db = getConnection(); return db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 0` ) .all() as SessionRow[]; }, /** * Archived rows are intentionally queried separately so the caller can render * them in a dedicated view without reintroducing them into active session lists. */ getArchivedSessions(): SessionRow[] { const db = getConnection(); return db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 1 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; }, getSessionsByProjectPath(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0` ) .all(normalizedProjectPath) as SessionRow[]; }, /** * Permanent project deletion must see every session row for the path, * including archived ones, so their transcript files can be cleaned up. */ getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ?` ) .all(normalizedProjectPath) as SessionRow[]; }, getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC LIMIT ? OFFSET ?` ) .all(normalizedProjectPath, limit, offset) as SessionRow[]; }, countSessionsByProjectPath(projectPath: string): number { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); const row = db .prepare( `SELECT COUNT(*) AS count FROM sessions WHERE project_path = ? AND isArchived = 0` ) .get(normalizedProjectPath) as { count: number } | undefined; return Number(row?.count ?? 0); }, deleteSessionsByProjectPath(projectPath: string): void { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath); }, getSessionName(sessionId: string, provider: string): string | null { const db = getConnection(); const row = db .prepare( `SELECT custom_name FROM sessions WHERE session_id = ? AND provider = ?` ) .get(sessionId, provider) as { custom_name: string | null } | undefined; return row?.custom_name ?? null; }, /** * Soft-delete and restore both use the same flag update so callers keep the * row, metadata, and file path intact while toggling visibility. */ updateSessionIsArchived(sessionId: string, isArchived: boolean): void { const db = getConnection(); db.prepare( `UPDATE sessions SET isArchived = ? WHERE session_id = ?` ).run(isArchived ? 1 : 0, sessionId); }, deleteSessionById(sessionId: string): boolean { const db = getConnection(); return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0; }, };