connect.go

 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}