// Package store is the SQLite persistence layer (modernc.org/sqlite, // no CGo). It owns the schema, exposes typed accessors, and hides // the database/sql plumbing from the rest of the server. package store import ( "context" "database/sql" "errors" "fmt" "net/url" "time" _ "modernc.org/sqlite" // register the "sqlite" driver ) // ErrNotFound is returned by accessors when a lookup misses. var ErrNotFound = errors.New("store: not found") // Store is a thin wrapper around *sql.DB that exposes the typed // accessors used by the rest of the server. Callers should use the // provided methods rather than reaching into DB() directly. type Store struct { db *sql.DB } // Open opens (or creates) the SQLite database at path, applies all // pending migrations, and returns a ready-to-use Store. // // The DSN sets: // - _pragma=foreign_keys(1) — referential integrity is on // - _pragma=journal_mode(WAL) — concurrent reads vs writes // - _pragma=busy_timeout(5000) — wait 5s on lock contention // - _time_format=sqlite — RFC 3339 read/write of TEXT timestamps // // Empty path uses an in-memory DB (useful for tests). func Open(ctx context.Context, path string) (*Store, error) { dsn := buildDSN(path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open %q: %w", path, err) } // modernc.org/sqlite is not safe for arbitrary high parallelism on // a single file. WAL helps, but 1 writer + multiple readers is the // only safe shape. Cap connections to keep that property explicit. db.SetMaxOpenConns(8) db.SetMaxIdleConns(4) db.SetConnMaxLifetime(time.Hour) pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := db.PingContext(pingCtx); err != nil { _ = db.Close() return nil, fmt.Errorf("ping: %w", err) } if err := migrate(ctx, db); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate: %w", err) } return &Store{db: db}, nil } // Close releases the underlying DB handle. func (s *Store) Close() error { return s.db.Close() } // DB returns the underlying *sql.DB. Reserved for tests and migrations // — production code should add a typed method to this package instead. func (s *Store) DB() *sql.DB { return s.db } func buildDSN(path string) string { if path == "" { // Shared cache + named in-memory db so multiple connections see // the same data — needed because we cap MaxOpenConns above. return "file::memory:?cache=shared&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" } q := url.Values{} q.Add("_pragma", "foreign_keys(1)") q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "synchronous(NORMAL)") return "file:" + path + "?" + q.Encode() }