package sqlite import ( "database/sql" "embed" "fmt" "os" "path/filepath" "sort" "strings" "time" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationFS embed.FS // DB wraps a SQLite connection and domain helpers. type DB struct { SQL *sql.DB Secret []byte // AES-256 key material for connection tokens (derived/padded) } // Open opens (or creates) the database at path and runs migrations. func Open(path string, secret []byte) (*DB, error) { if path == "" { return nil, fmt.Errorf("sqlite: path required") } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("sqlite: create dir: %w", err) } sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") if err != nil { return nil, fmt.Errorf("sqlite: open: %w", err) } sqlDB.SetMaxOpenConns(1) // SQLite writer safety if err := sqlDB.Ping(); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("sqlite: ping: %w", err) } db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)} if err := db.migrate(); err != nil { _ = sqlDB.Close() return nil, err } return db, nil } // Close closes the underlying connection. func (db *DB) Close() error { if db == nil || db.SQL == nil { return nil } return db.SQL.Close() } func (db *DB) migrate() error { if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL )`); err != nil { return fmt.Errorf("sqlite: schema_migrations: %w", err) } entries, err := migrationFS.ReadDir("migrations") if err != nil { return fmt.Errorf("sqlite: list migrations: %w", err) } var names []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { names = append(names, e.Name()) } } sort.Strings(names) for _, name := range names { var exists string err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists) if err == nil { continue } if err != sql.ErrNoRows { return fmt.Errorf("sqlite: check migration %s: %w", name, err) } body, err := migrationFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("sqlite: read migration %s: %w", name, err) } tx, err := db.SQL.Begin() if err != nil { return err } if _, err := tx.Exec(string(body)); err != nil { _ = tx.Rollback() return fmt.Errorf("sqlite: apply %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { _ = tx.Rollback() return fmt.Errorf("sqlite: record %s: %w", name, err) } if err := tx.Commit(); err != nil { return err } } return nil } func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339Nano) } func parseTime(s string) time.Time { s = strings.TrimSpace(s) if s == "" { return time.Time{} } for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { if t, err := time.Parse(layout, s); err == nil { return t.UTC() } } return time.Time{} }