initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
307
internal/store/store.go
Normal file
307
internal/store/store.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue