// Package store is the persistence layer. It owns the SQLite connection, the // schema and every query the application runs. // // # Why SQLite // // Go Bananas!! stores themes: a few thousand rows of JSON documents that are read // constantly and written rarely. That workload is exactly what an embedded // engine is good at, and a client/server database would add a network hop, a // connection pool, a second container, credentials to rotate and a schema // migration process to a service whose entire dataset comfortably fits in a few // megabytes. // // The configuration that makes this genuinely fast rather than merely adequate: // // - WAL journalling, so readers never block the writer and vice versa. // - synchronous=NORMAL, which in WAL mode is durable across process crashes // and only risks the last transactions on an OS crash. For a design-token // store that is the correct trade: a lost publish is re-doable, an outage // is not. // - A deliberately small connection pool. SQLite serialises writers, so many // connections buy contention, not throughput. // - Prepared statements cached by database/sql, so the hot runtime query is // compiled once at start-up. // - Rendered themes memoised in memory, so the steady-state runtime request // performs no database access at all. // // The engine is modernc.org/sqlite, a pure-Go translation of SQLite. It needs no // cgo, so the release image builds from a static base with no libc and the // cross-compiled binaries are genuinely portable. // // # Concurrency // // A Store is safe for concurrent use. The connection pool handles concurrency; // write transactions are serialised by SQLite's own locking, which at this write // volume is not a bottleneck. Callers must not hold a *sql.Tx across an HTTP // boundary; use the callback forms so that a panic rolls the transaction back. package store import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "time" _ "modernc.org/sqlite" // pure-Go SQLite driver "github.com/urpi/go-bananas/internal/domain" ) // marshalJSON encodes a value for a TEXT column. The store always writes JSON // through this helper so that a value which cannot be marshalled fails at the // boundary rather than producing a corrupt row. func marshalJSON(v any) (string, error) { raw, err := json.Marshal(v) if err != nil { return "", fmt.Errorf("marshal json: %w", err) } return string(raw), nil } // boolToInt renders a bool for a SQLite INTEGER column. func boolToInt(b bool) int { if b { return 1 } return 0 } // intToBool is the inverse of boolToInt. func intToBool(v int) bool { return v != 0 } // Store is the persistence facade. type Store struct { db *sql.DB now func() time.Time // inMemory reports whether the store is backed by an ephemeral database. inMemory bool } // Options configures a Store. type Options struct { // Path is the database file, or ":memory:" for an ephemeral database. Path string // BusyTimeout is how long a blocked writer waits before failing. BusyTimeout time.Duration // MaxConns bounds the connection pool. MaxConns int // ReadOnly opens the database without write access, for replicas. ReadOnly bool // Now overrides the clock, for deterministic tests. Now func() time.Time } // sentinelMapping translates the SQLite driver's typed errors into the domain // sentinels the HTTP layer matches on. Doing this in one place keeps every // repository from re-implementing driver-specific string matching. var sentinelMapping = map[string]error{ "constraint failed: UNIQUE constraint failed": domain.ErrConflict, "constraint failed: CHECK constraint failed": domain.ErrInvalid, "constraint failed: NOT NULL constraint failed": domain.ErrInvalid, "constraint failed: FOREIGN KEY constraint failed": domain.ErrConflict, } // Open connects to the database, applies the schema and verifies it. func Open(ctx context.Context, opts Options) (*Store, error) { if opts.Path == "" { return nil, fmt.Errorf("%w: database path must not be empty", domain.ErrInvalid) } if opts.BusyTimeout <= 0 { opts.BusyTimeout = 5 * time.Second } if opts.MaxConns <= 0 { opts.MaxConns = 8 } if opts.Now == nil { opts.Now = time.Now } inMemory := isMemoryPath(opts.Path) dsn := buildDSN(opts.Path, opts.BusyTimeout, opts.ReadOnly) if !inMemory && !opts.ReadOnly { if dir := filepath.Dir(opts.Path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create database directory: %w", err) } } } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // A small pool is deliberate. See the package comment. db.SetMaxOpenConns(opts.MaxConns) db.SetMaxIdleConns(opts.MaxConns) db.SetConnMaxIdleTime(time.Minute) if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("ping database: %w", err) } s := &Store{db: db, now: opts.Now, inMemory: inMemory} if !opts.ReadOnly { if err := s.migrate(ctx); err != nil { db.Close() return nil, err } } return s, nil } // buildDSN assembles the SQLite connection string. // // modernc.org/sqlite takes its PRAGMAs as query parameters. Using the _pragma // form rather than executing PRAGMA statements after opening is what makes the // settings apply to every pooled connection instead of only the first. func buildDSN(path string, busyTimeout time.Duration, readOnly bool) string { params := url.Values{} // WAL lets readers and the writer proceed concurrently. if !readOnly && !isMemoryPath(path) { params.Add("_pragma", "journal_mode(WAL)") } // NORMAL is durable across application crashes in WAL mode. See the package // comment for why that is the right trade here. params.Add("_pragma", "synchronous(NORMAL)") params.Add("_pragma", "foreign_keys(ON)") params.Add("_pragma", "busy_timeout("+fmt.Sprint(busyTimeout.Milliseconds())+")") // A negative cache size is a KiB budget; 16 MiB comfortably holds the whole // dataset in memory. params.Add("_pragma", "cache_size(-16000)") // temp_store=MEMORY keeps sort and group-by spill out of the filesystem. params.Add("_pragma", "temp_store(MEMORY)") // mmap lets SQLite read the file through the page cache directly, which // removes a copy on the hot read path. params.Add("_pragma", "mmap_size(268435456)") if readOnly { params.Add("mode", "ro") } base := path if isMemoryPath(path) { // A shared cache is required for an in-memory database to be visible // across the pooled connections, otherwise each connection would get its // own empty database. switch { case strings.Contains(path, "?"): // The caller supplied a full URI with its own query. base = path case nameFromPath(path) != "": base = "file:" + nameFromPath(path) + "?mode=memory&cache=shared" default: // A bare ":memory:" carries no name. Naming it uniquely keeps two // stores in one process isolated from each other, which is what a test // creating a second store expects; leaving it unnamed would hand every // caller the same database and let one test's rows appear in another's. base = "file:" + ephemeralMemoryName() + "?mode=memory&cache=shared" } } // The separator depends on whether the base already carries a query. Joining // with "?" unconditionally produces "cache=shared?_pragma=...", which SQLite // reads as a cache mode literally named "shared?_pragma=..." and rejects. sep := "?" if strings.Contains(base, "?") { sep = "&" } return base + sep + params.Encode() } // ephemeralMemoryName returns a random name for an unnamed in-memory database. func ephemeralMemoryName() string { buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { // A fixed name is wrong but not fatal: the store still opens and the only // consequence is that two concurrent unnamed stores would share a database. // Failing to open over an entropy hiccup would be worse than that. return "memory-default" } return "memory-" + hex.EncodeToString(buf) } // IsPersistentPath reports whether a database path holds data that outlives the // process. An ephemeral database cannot be snapshotted, and a caller that tries is // better served by being told so than by producing an empty file. func IsPersistentPath(path string) bool { return !isMemoryPath(path) } // isMemoryPath reports whether the path refers to an ephemeral database. func isMemoryPath(path string) bool { return path == ":memory:" || strings.HasPrefix(path, "file::memory:") || strings.Contains(path, "mode=memory") } // nameFromPath derives a stable shared-cache identifier from a path, so that // two in-memory stores created with the same name are genuinely the same // database and two created with different names are isolated. func nameFromPath(path string) string { name := strings.TrimPrefix(path, ":memory:") name = strings.TrimPrefix(name, "file:") if idx := strings.IndexByte(name, '?'); idx >= 0 { name = name[:idx] } name = strings.Trim(name, "/") if name == "" { return "go-bananas-memory" } // Names are hashed rather than escaped so that any path can be used safely. return "go-bananas-" + fmt.Sprintf("%x", len(name)) + "-" + strings.ReplaceAll(name, "/", "_") } // Close releases the database. func (s *Store) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } // DB exposes the underlying handle for health checks and tests. func (s *Store) DB() *sql.DB { return s.db } // Ping verifies connectivity, used by the readiness probe. func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } // InMemory reports whether the store is ephemeral. func (s *Store) InMemory() bool { return s.inMemory } // Now returns the store's clock. func (s *Store) Now() time.Time { return s.now() } // translateError maps a driver error onto a domain sentinel. func translateError(err error) error { if err == nil { return nil } if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error()) } msg := err.Error() for fragment, sentinel := range sentinelMapping { if strings.Contains(msg, fragment) { return fmt.Errorf("%w: %s", sentinel, msg) } } return err } // wrapNotFound attaches context to an ErrNotFound result. func wrapNotFound(err error, what string) error { if err == nil { return nil } if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: %s", domain.ErrNotFound, what) } return translateError(err) }