1package db
2
3import (
4 "context"
5 "database/sql"
6 "embed"
7 "fmt"
8 "log/slog"
9 "path/filepath"
10 "sync"
11 "testing"
12
13 "github.com/pressly/goose/v3"
14)
15
16var (
17 pragmas = map[string]string{
18 "foreign_keys": "ON",
19 "journal_mode": "WAL",
20 "page_size": "4096",
21 "cache_size": "-8000",
22 "synchronous": "NORMAL",
23 "secure_delete": "ON",
24 "busy_timeout": "30000",
25 }
26 gooseInitOnce sync.Once
27 gooseInitErr error
28)
29
30//go:embed migrations/*.sql
31var FS embed.FS
32
33func init() {
34 goose.SetBaseFS(FS)
35
36 if testing.Testing() {
37 goose.SetLogger(goose.NopLogger())
38 }
39}
40
41// Connect opens a SQLite database connection and runs migrations.
42func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
43 if dataDir == "" {
44 return nil, fmt.Errorf("data.dir is not set")
45 }
46 dbPath := filepath.Join(dataDir, "crush.db")
47
48 db, err := openDB(dbPath)
49 if err != nil {
50 return nil, err
51 }
52
53 // Serialize all access through a single connection. SQLite serializes
54 // writes at the file level anyway, and allowing multiple pool
55 // connections to interleave writes/checkpoints (especially under
56 // concurrent sub-agents) has caused WAL/header desync resulting in
57 // SQLITE_NOTADB (26) on the next open.
58 db.SetMaxOpenConns(1)
59
60 if err = db.PingContext(ctx); err != nil {
61 db.Close()
62 return nil, fmt.Errorf("failed to connect to database: %w", err)
63 }
64
65 if err := initGoose(); err != nil {
66 slog.Error("Failed to initialize goose", "error", err)
67 return nil, fmt.Errorf("failed to initialize goose: %w", err)
68 }
69
70 if err := goose.Up(db, "migrations"); err != nil {
71 slog.Error("Failed to apply migrations", "error", err)
72 return nil, fmt.Errorf("failed to apply migrations: %w", err)
73 }
74
75 return db, nil
76}
77
78func initGoose() error {
79 gooseInitOnce.Do(func() {
80 goose.SetBaseFS(FS)
81 gooseInitErr = goose.SetDialect("sqlite3")
82 })
83
84 return gooseInitErr
85}