143 lines
5.0 KiB
Go
143 lines
5.0 KiB
Go
// Package db manages the SQLite connection and schema migrations for Cyrene v2.
|
|
//
|
|
// SQLite is the default embedded store. The pool is limited to a single open
|
|
// connection (SetMaxOpenConns(1) plus SetMaxIdleConns(1)) so all writes are
|
|
// serialized and can never contend on SQLite's single-writer file lock. A
|
|
// busy_timeout of 5000ms and WAL journal mode for file-backed databases
|
|
// further reduce lock contention.
|
|
//
|
|
// Migrations live in internal/db/migrations/*.sql, are embedded into the
|
|
// binary, and are applied in timestamp order inside transactions, tracked in a
|
|
// schema_migrations table. See MigrateUp and MigrateDown.
|
|
//
|
|
// PostgreSQL + pgvector is an optional enhancement that is intentionally out of
|
|
// scope for milestone M0. It will be added when the runtime reaches milestone M4
|
|
// (see DevDocs/开发时间线.md); until then the connection is SQLite only.
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite" // pure-Go SQLite driver, registers driver name "sqlite"
|
|
)
|
|
|
|
// Package-level sentinel errors. Each is wrapped with %w at the call site so
|
|
// callers can use errors.Is.
|
|
var (
|
|
// ErrInvalidPath is returned when the database path is empty or blank.
|
|
ErrInvalidPath = errors.New("db: invalid database path")
|
|
|
|
// ErrNoEmbedded is returned when no migration files are embedded.
|
|
ErrNoEmbedded = errors.New("db: no migration files found in embed")
|
|
|
|
// ErrDownMissing is returned when a migration has an up file but no matching
|
|
// down file.
|
|
ErrDownMissing = errors.New("db: down migration file missing")
|
|
|
|
// ErrInvalidSteps is returned when MigrateDown receives a steps count < 1.
|
|
ErrInvalidSteps = errors.New("db: migration steps must be >= 1")
|
|
)
|
|
|
|
// Open opens (or creates) the SQLite database at path and returns a *sql.DB.
|
|
//
|
|
// path may be a filesystem path (relative or absolute) or ":memory:" for an
|
|
// isolated in-memory database, which tests use. Under ":memory:" each returned
|
|
// handle owns exactly one private, non-shared database, so parallel tests are
|
|
// isolated by construction.
|
|
//
|
|
// The pool is configured for write serialization: SetMaxOpenConns(1) and
|
|
// SetMaxIdleConns(1) prevent multiple writers from contending on the single-
|
|
// writer lock. busy_timeout is set to 5000ms; for file-backed databases the
|
|
// journal mode is switched to WAL. ":memory:" keeps the default memory journal,
|
|
// because WAL has no meaning there.
|
|
//
|
|
// log is a *slog.Logger; a nil logger is replaced with a discard logger so the
|
|
// function can always log safely. ctx is used for the connection ping and the
|
|
// WAL pragma. The caller must Close the returned DB when done.
|
|
func Open(ctx context.Context, path string, log *slog.Logger) (*sql.DB, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, fmt.Errorf("db: open: %w", ErrInvalidPath)
|
|
}
|
|
log = discardLogger(log)
|
|
|
|
// Ensure the parent directory exists for file-backed databases so a custom
|
|
// DATA_DIR (e.g. ./data) works on first run without a pre-made directory.
|
|
if !isMemory(path) {
|
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("db: create dir %q: %w", dir, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", buildDSN(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db: open %q: %w", path, err)
|
|
}
|
|
// Write serialization: a single connection avoids writer-lock contention.
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
|
|
if !isMemory(path) {
|
|
if _, err := db.ExecContext(ctx, "PRAGMA journal_mode=WAL"); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("db: enable WAL on %q: %w", path, err)
|
|
}
|
|
}
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("db: ping %q: %w", path, err)
|
|
}
|
|
|
|
log.Info("sqlite opened", "path", path, "max_open_conns", 1)
|
|
return db, nil
|
|
}
|
|
|
|
// buildDSN converts a user-supplied path into a modernc.org/sqlite DSN.
|
|
//
|
|
// ":memory:" is returned as-is: the driver then gives that handle a private,
|
|
// non-shared in-memory database, which with SetMaxOpenConns(1) is stable for
|
|
// the lifetime of the handle. Any other path becomes a file: URI so the
|
|
// busy_timeout pragma is honored; pre-built file: URIs are preserved and only
|
|
// have the pragma appended.
|
|
func buildDSN(path string) string {
|
|
if path == ":memory:" || strings.Contains(path, "mode=memory") {
|
|
return path
|
|
}
|
|
|
|
dsn := path
|
|
if !strings.HasPrefix(path, "file:") {
|
|
dsn = "file:" + path
|
|
}
|
|
sep := "?"
|
|
if strings.Contains(dsn, "?") {
|
|
sep = "&"
|
|
}
|
|
return dsn + sep + "_pragma=busy_timeout(5000)"
|
|
}
|
|
|
|
// isMemory reports whether path refers to an in-memory SQLite database, in
|
|
// which case the WAL journal mode is skipped.
|
|
func isMemory(path string) bool {
|
|
return strings.Contains(strings.ToLower(path), ":memory:") ||
|
|
strings.Contains(strings.ToLower(path), "mode=memory")
|
|
}
|
|
|
|
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere,
|
|
// so callers can always log safely.
|
|
func discardLogger(log *slog.Logger) *slog.Logger {
|
|
if log != nil {
|
|
return log
|
|
}
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|