From a2ce1fa41d4a765637c7ec8916c8f2206772bd4c Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Thu, 23 Apr 2026 00:53:05 +0100 Subject: [PATCH] feat(observabilidade): wrapper SQLite com schema, upsert e filtros --- api/services/sessions/db.ts | 169 ++++++++++++++++++++++++++++++++++ api/tests/sessions-db.test.ts | 71 ++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 api/services/sessions/db.ts create mode 100644 api/tests/sessions-db.test.ts diff --git a/api/services/sessions/db.ts b/api/services/sessions/db.ts new file mode 100644 index 0000000..6e55745 --- /dev/null +++ b/api/services/sessions/db.ts @@ -0,0 +1,169 @@ +import Database from 'better-sqlite3' +import { mkdirSync } from 'fs' +import { dirname } from 'path' +import type { SessionMeta } from '../../types/session.js' + +export interface ListFilters { + days?: number + project?: string + tool?: string + skill?: string + q?: string + limit?: number + offset?: number +} + +export interface SessionsDb { + upsertSession(meta: SessionMeta): void + listSessions(filters: ListFilters): SessionMeta[] + countSessions(filters: ListFilters): number + getSession(id: string): SessionMeta | null + deleteByJsonlPath(path: string): void + close(): void +} + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project_path TEXT NOT NULL, + project_slug TEXT NOT NULL, + jsonl_path TEXT NOT NULL UNIQUE, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_sec INTEGER, + event_count INTEGER NOT NULL, + user_messages INTEGER NOT NULL, + assistant_msgs INTEGER NOT NULL, + tool_calls INTEGER NOT NULL, + first_prompt TEXT, + tools_used TEXT NOT NULL, + skills_invoked TEXT NOT NULL, + outcome TEXT NOT NULL, + permission_mode TEXT, + file_size INTEGER NOT NULL, + indexed_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC); +CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC); +` + +function rowToMeta(row: Record): SessionMeta { + return { + session_id: row.session_id as string, + project_path: row.project_path as string, + project_slug: row.project_slug as string, + jsonl_path: row.jsonl_path as string, + started_at: row.started_at as string, + ended_at: (row.ended_at as string | null) ?? null, + duration_sec: (row.duration_sec as number | null) ?? null, + event_count: row.event_count as number, + user_messages: row.user_messages as number, + assistant_msgs: row.assistant_msgs as number, + tool_calls: row.tool_calls as number, + first_prompt: (row.first_prompt as string | null) ?? null, + tools_used: JSON.parse(row.tools_used as string), + skills_invoked: JSON.parse(row.skills_invoked as string), + outcome: row.outcome as SessionMeta['outcome'], + permission_mode: (row.permission_mode as string | null) ?? null, + file_size: row.file_size as number, + indexed_at: row.indexed_at as string, + } +} + +function buildWhere(f: ListFilters): { sql: string; params: Record } { + const parts: string[] = [] + const params: Record = {} + if (f.days) { + const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString() + parts.push('started_at >= @cutoff') + params.cutoff = cutoff + } + if (f.project) { + parts.push('project_slug = @project') + params.project = f.project + } + if (f.tool) { + parts.push("tools_used LIKE @toolLike") + params.toolLike = `%"${f.tool}"%` + } + if (f.skill) { + parts.push('skills_invoked LIKE @skillLike') + params.skillLike = `%"${f.skill}"%` + } + if (f.q) { + parts.push('first_prompt LIKE @q') + params.q = `%${f.q}%` + } + return { + sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '', + params, + } +} + +export function openSessionsDb(dbPath: string): SessionsDb { + mkdirSync(dirname(dbPath), { recursive: true }) + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.exec(SCHEMA) + + const upsertStmt = db.prepare(` + INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at, + duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt, + tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at) + VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at, + @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt, + @tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at) + ON CONFLICT(session_id) DO UPDATE SET + project_path = excluded.project_path, + project_slug = excluded.project_slug, + jsonl_path = excluded.jsonl_path, + started_at = excluded.started_at, + ended_at = excluded.ended_at, + duration_sec = excluded.duration_sec, + event_count = excluded.event_count, + user_messages = excluded.user_messages, + assistant_msgs = excluded.assistant_msgs, + tool_calls = excluded.tool_calls, + first_prompt = excluded.first_prompt, + tools_used = excluded.tools_used, + skills_invoked = excluded.skills_invoked, + outcome = excluded.outcome, + permission_mode = excluded.permission_mode, + file_size = excluded.file_size, + indexed_at = excluded.indexed_at + `) + + return { + upsertSession(meta) { + upsertStmt.run({ + ...meta, + tools_used: JSON.stringify(meta.tools_used), + skills_invoked: JSON.stringify(meta.skills_invoked), + }) + }, + listSessions(filters) { + const { sql, params } = buildWhere(filters) + const limit = filters.limit ?? 50 + const offset = filters.offset ?? 0 + const rows = db + .prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`) + .all({ ...params, limit, offset }) as Record[] + return rows.map(rowToMeta) + }, + countSessions(filters) { + const { sql, params } = buildWhere(filters) + const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number } + return row.c + }, + getSession(id) { + const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record | undefined + return row ? rowToMeta(row) : null + }, + deleteByJsonlPath(path) { + db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path) + }, + close() { + db.close() + }, + } +} diff --git a/api/tests/sessions-db.test.ts b/api/tests/sessions-db.test.ts new file mode 100644 index 0000000..38e7a45 --- /dev/null +++ b/api/tests/sessions-db.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { openSessionsDb } from '../services/sessions/db.js' +import type { SessionMeta } from '../types/session.js' + +function sampleMeta(overrides: Partial = {}): SessionMeta { + return { + session_id: 's1', + project_path: '/tmp/project', + project_slug: 'project', + jsonl_path: '/tmp/project/s1.jsonl', + started_at: '2026-04-23T10:00:00Z', + ended_at: '2026-04-23T10:30:00Z', + duration_sec: 1800, + event_count: 50, + user_messages: 5, + assistant_msgs: 10, + tool_calls: 20, + first_prompt: 'olá', + tools_used: ['Bash', 'Read'], + skills_invoked: ['brainstorming'], + outcome: 'completed', + permission_mode: 'default', + file_size: 10000, + indexed_at: '2026-04-23T10:31:00Z', + ...overrides, + } +} + +describe('sessions db', () => { + let dbPath: string + beforeEach(() => { + const dir = mkdtempSync(join(tmpdir(), 'obs-db-')) + dbPath = join(dir, 'sessions.db') + }) + + it('cria schema, faz upsert e query', () => { + const db = openSessionsDb(dbPath) + db.upsertSession(sampleMeta()) + const rows = db.listSessions({ days: 30 }) + expect(rows).toHaveLength(1) + expect(rows[0].session_id).toBe('s1') + expect(rows[0].tools_used).toEqual(['Bash', 'Read']) + }) + + it('upsert substitui registo existente', () => { + const db = openSessionsDb(dbPath) + db.upsertSession(sampleMeta({ event_count: 50 })) + db.upsertSession(sampleMeta({ event_count: 75 })) + const rows = db.listSessions({ days: 30 }) + expect(rows).toHaveLength(1) + expect(rows[0].event_count).toBe(75) + }) + + it('filtra por projecto e tool', () => { + const db = openSessionsDb(dbPath) + db.upsertSession(sampleMeta({ session_id: 'a', jsonl_path: '/tmp/a.jsonl', project_slug: 'alpha', tools_used: ['Bash'] })) + db.upsertSession(sampleMeta({ session_id: 'b', jsonl_path: '/tmp/b.jsonl', project_slug: 'beta', tools_used: ['Read'] })) + expect(db.listSessions({ project: 'alpha' })).toHaveLength(1) + expect(db.listSessions({ tool: 'Read' })).toHaveLength(1) + }) + + it('devolve contagem total', () => { + const db = openSessionsDb(dbPath) + db.upsertSession(sampleMeta({ session_id: 'a', jsonl_path: '/tmp/a.jsonl' })) + db.upsertSession(sampleMeta({ session_id: 'b', jsonl_path: '/tmp/b.jsonl' })) + expect(db.countSessions({})).toBe(2) + }) +})