// Package store persists proxy history to SQLite in WAL mode. Request // and response bytes are stored as-received where possible (see the // Exact fields) rather than re-serialized from a parsed representation. package store import ( "database/sql" "fmt" "time" _ "modernc.org/sqlite" ) const schema = ` CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at INTEGER NOT NULL, duration_ms INTEGER NOT NULL, method TEXT NOT NULL, scheme TEXT NOT NULL, host TEXT NOT NULL, path TEXT NOT NULL, status_code INTEGER, request_raw BLOB NOT NULL, response_raw BLOB, request_exact INTEGER NOT NULL, response_exact INTEGER NOT NULL, error TEXT NOT NULL DEFAULT '' ); ` // Store is a handle to the history database. Safe for concurrent use. type Store struct { db *sql.DB } // Open opens (creating if needed) the SQLite database at path in WAL mode. func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open db: %w", err) } // modernc.org/sqlite has no real connection pooling benefit here and // SQLite only supports one writer at a time; serializing access // through a single connection avoids SQLITE_BUSY entirely. db.SetMaxOpenConns(1) for _, pragma := range []string{ "PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL", "PRAGMA foreign_keys = ON", } { if _, err := db.Exec(pragma); err != nil { db.Close() return nil, fmt.Errorf("%s: %w", pragma, err) } } if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("create schema: %w", err) } return &Store{db: db}, nil } // Close closes the underlying database. func (s *Store) Close() error { return s.db.Close() } // Entry is one captured request/response pair. type Entry struct { ID int64 StartedAt time.Time Duration time.Duration Method string Scheme string Host string Path string StatusCode int // 0 if no response was received RequestRaw []byte ResponseRaw []byte // nil if no response was received RequestExact bool // true if RequestRaw is wire-exact, false if reconstructed (e.g. HTTP/2) ResponseExact bool Error string // network/transport error, if the request never got a response } // Summary is the lightweight metadata used for the history list view - // no request/response bodies. type Summary struct { ID int64 StartedAt time.Time Duration time.Duration Method string Scheme string Host string Path string StatusCode int ReqSize int RespSize int Error string } // Insert stores e and returns its assigned ID. func (s *Store) Insert(e *Entry) (int64, error) { var statusCode any if e.StatusCode != 0 { statusCode = e.StatusCode } res, err := s.db.Exec( `INSERT INTO history (started_at, duration_ms, method, scheme, host, path, status_code, request_raw, response_raw, request_exact, response_exact, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.StartedAt.UnixMilli(), e.Duration.Milliseconds(), e.Method, e.Scheme, e.Host, e.Path, statusCode, e.RequestRaw, e.ResponseRaw, boolToInt(e.RequestExact), boolToInt(e.ResponseExact), e.Error, ) if err != nil { return 0, fmt.Errorf("insert history entry: %w", err) } return res.LastInsertId() } // List returns up to limit history summaries older than beforeID (or the // most recent if beforeID is 0), newest first. func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { if limit <= 0 || limit > 1000 { limit = 200 } if beforeID <= 0 { beforeID = 1<<63 - 1 } rows, err := s.db.Query( `SELECT id, started_at, duration_ms, method, scheme, host, path, COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error FROM history WHERE id < ? ORDER BY id DESC LIMIT ?`, beforeID, limit, ) if err != nil { return nil, fmt.Errorf("list history: %w", err) } defer rows.Close() var out []Summary for rows.Next() { var sum Summary var startedAt, durationMs int64 if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error); err != nil { return nil, fmt.Errorf("scan history row: %w", err) } sum.StartedAt = time.UnixMilli(startedAt) sum.Duration = time.Duration(durationMs) * time.Millisecond out = append(out, sum) } return out, rows.Err() } // Get returns the full entry (including raw bytes) for id. func (s *Store) Get(id int64) (*Entry, error) { row := s.db.QueryRow( `SELECT id, started_at, duration_ms, method, scheme, host, path, COALESCE(status_code, 0), request_raw, response_raw, request_exact, response_exact, error FROM history WHERE id = ?`, id, ) var e Entry var startedAt, durationMs int64 var reqExact, respExact int if err := row.Scan(&e.ID, &startedAt, &durationMs, &e.Method, &e.Scheme, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error); err != nil { return nil, fmt.Errorf("get history entry %d: %w", id, err) } e.StartedAt = time.UnixMilli(startedAt) e.Duration = time.Duration(durationMs) * time.Millisecond e.RequestExact = reqExact != 0 e.ResponseExact = respExact != 0 return &e, nil } func boolToInt(b bool) int { if b { return 1 } return 0 }