NextMyDay/internal/core/store/store.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

99 lines
3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package store owns MyDay's SQLite connection and migration runner
// (PROJECT_PLAN.md §12.1: "the core owns the DB connection and the migration
// runner; each module owns its own tables, queries, and migrations").
//
// It uses the CGO-free pure-Go driver modernc.org/sqlite (D-012), so the
// shipped binary stays static — no mattn/go-sqlite3, no libc dependency — and
// runs with WAL journaling and foreign keys ON.
//
// Spec: docs/specs/spec-004-identity-sessions.md §3–§4.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite" // registers the "sqlite" driver (D-012)
)
// Driver is the SQLite driver name registered by modernc.org/sqlite (D-012).
const Driver = "sqlite"
// DB wraps the standard connection pool. The embedded *sql.DB is what the core
// services (users, auth, audit) consume.
type DB struct {
*sql.DB
path string
}
// Open opens (creating if needed) the SQLite database at path.
//
// The DSN carries the connection pragmas so they apply to every connection the
// pool opens: WAL journaling, foreign keys ON, a busy timeout, and NORMAL
// synchronous (safe with WAL, faster than FULL).
//
// SQLite is a single-writer store, so the pool is pinned to ONE connection:
// this removes lock contention (D-012: "one instance only") and keeps an
// in-memory database coherent across the whole process.
//
// A non-empty ":memory:" (or file:...mode=memory) path opens a private
// in-memory database — used by the unit tests.
func Open(path string) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("store: database path is empty")
}
if !isMemory(path) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("store: create data directory %q: %w", dir, err)
}
}
}
db, err := sql.Open(Driver, buildDSN(path))
if err != nil {
return nil, fmt.Errorf("store: open %q: %w", path, err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: ping %q: %w", path, err)
}
return &DB{DB: db, path: path}, nil
}
// Path returns the database path this connection was opened with.
func (db *DB) Path() string { return db.path }
// isMemory reports whether path names an in-memory database.
func isMemory(path string) bool {
return path == ":memory:" || strings.Contains(path, "mode=memory")
}
// buildDSN assembles the modernc.org/sqlite DSN. The `_pragma` parameters are
// applied on every connection open (D-012: WAL · foreign keys ON).
func buildDSN(path string) string {
pragmas := []string{
"journal_mode(WAL)",
"foreign_keys(1)",
"busy_timeout(5000)",
"synchronous(NORMAL)",
}
var b strings.Builder
if isMemory(path) {
b.WriteString("file::memory:")
} else {
b.WriteString("file:")
b.WriteString(path)
}
b.WriteString("?_pragma=")
b.WriteString(strings.Join(pragmas, "&_pragma="))
return b.String()
}