// 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)) }