srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/store.go186
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
+}