feat(observabilidade): wrapper SQLite com schema, upsert e filtros
This commit is contained in:
@@ -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<string, unknown>): 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<string, unknown> } {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
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<string, unknown>[]
|
||||
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<string, unknown> | undefined
|
||||
return row ? rowToMeta(row) : null
|
||||
},
|
||||
deleteByJsonlPath(path) {
|
||||
db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path)
|
||||
},
|
||||
close() {
|
||||
db.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user