// Package store owns the SQLite database: connection management, migrations and // every query the server runs. It is server-side only — the CLI must never reach // it, and cmd/pages/deps_test.go enforces that. package store import ( "context" "database/sql" "errors" "fmt" "log/slog" "math/rand/v2" "net/url" "runtime" "time" "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" ) const driverName = "sqlite" // maxTxAttempts bounds the BEGIN-level retry loop. busy_timeout already handles // contention inside a transaction; these retries exist for the cases it cannot // cover (see Tx). const maxTxAttempts = 5 // DB holds the two connection pools SQLite wants under concurrent load. // // SQLite permits exactly one writer at a time. Handing database/sql a single // pool means readers and the writer compete for the same connections and every // static request can end up queued behind a deployment commit. Two pools make // the rule explicit instead: W is capped at one connection so writes serialise // in Go (where waiting is cheap and fair) rather than in SQLite (where it // surfaces as SQLITE_BUSY), and R holds the concurrent readers that WAL mode // lets run undisturbed alongside the writer. type DB struct { w *sql.DB r *sql.DB path string log *slog.Logger } // Open connects to the database at path, applying the pragmas both pools need, // and runs any outstanding migrations. func Open(ctx context.Context, path string, log *slog.Logger) (*DB, error) { w, err := sql.Open(driverName, dsn(path, false)) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } // One writer, by construction. Idle == open and no lifetime cap: rebuilding a // SQLite connection means replaying every pragma, and a server-side // connection has nothing to go stale against. w.SetMaxOpenConns(1) w.SetMaxIdleConns(1) w.SetConnMaxLifetime(0) w.SetConnMaxIdleTime(0) // The writer opens first so the database file, and its WAL, exist before any // read-only connection tries to attach to them. if err := w.PingContext(ctx); err != nil { w.Close() return nil, fmt.Errorf("open %s: %w", path, err) } readers := max(4, runtime.NumCPU()) r, err := sql.Open(driverName, dsn(path, true)) if err != nil { w.Close() return nil, fmt.Errorf("open %s (read pool): %w", path, err) } r.SetMaxOpenConns(readers) r.SetMaxIdleConns(readers) r.SetConnMaxLifetime(0) r.SetConnMaxIdleTime(0) if err := r.PingContext(ctx); err != nil { w.Close() r.Close() return nil, fmt.Errorf("open %s (read pool): %w", path, err) } d := &DB{w: w, r: r, path: path, log: log} if err := d.migrate(ctx); err != nil { d.Close() return nil, err } return d, nil } // dsn builds the connection string. The pragmas are per-connection state, so // every connection in both pools must carry them. func dsn(path string, readOnly bool) string { q := url.Values{} // Ordering inside the driver is fixed (busy_timeout first), so the list here // is grouped by intent rather than by application order. q.Add("_pragma", "busy_timeout(10000)") // wait for a lock instead of failing q.Add("_pragma", "journal_mode(WAL)") // readers do not block the writer q.Add("_pragma", "synchronous(NORMAL)") // the correct pairing with WAL q.Add("_pragma", "foreign_keys(ON)") // off by default in SQLite q.Add("_pragma", "recursive_triggers(ON)") q.Add("_pragma", "temp_store(MEMORY)") q.Add("_pragma", "wal_autocheckpoint(1000)") if readOnly { // A guard rail, not a security boundary: it turns "this query was meant to // be a read" from a silent lock contention bug into an immediate error. q.Set("_query_only", "true") } else { // Every transaction takes the write lock at BEGIN. Without this a // transaction that starts with a SELECT and later writes must upgrade its // lock, and an upgrade that finds another writer fails with SQLITE_BUSY // immediately — busy_timeout does not apply to it, because waiting could // only ever deadlock. q.Set("_txlock", "immediate") } return "file:" + path + "?" + q.Encode() } // Reader returns the read-only pool. Queries that must observe uncommitted // changes belong inside the writing transaction instead. func (d *DB) Reader() *sql.DB { return d.r } // Path is the database file's location. func (d *DB) Path() string { return d.path } // Tx runs fn inside a single write transaction and commits it, retrying from // the top when SQLite reports contention. // // fn may be called more than once, so it must not have side effects outside the // transaction — no file writes, no atomic pointer stores, no channel sends. // Publishing a change to the rest of the process is the caller's job, after Tx // returns nil. func (d *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error { var err error for attempt := range maxTxAttempts { if attempt > 0 { // Exponential backoff with jitter, so two contending writers do not // retry in lockstep. backoff := time.Duration(1<