diff options
Diffstat (limited to 'internal/store/store.go')
| -rw-r--r-- | internal/store/store.go | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c0219e4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,186 @@ +// 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 +} |