db: add sqlite "source of truth" schema

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby
2025-05-21 11:08:33 +02:00
committed by Kristoffer Dalby
parent 855c48aec2
commit c6736dd6d6
248 changed files with 6228 additions and 207 deletions

View File

@@ -0,0 +1,345 @@
// Package sqliteconfig provides type-safe configuration for SQLite databases
// with proper enum validation and URL generation for modernc.org/sqlite driver.
package sqliteconfig
import (
"errors"
"fmt"
"strings"
)
// Errors returned by config validation.
var (
ErrPathEmpty = errors.New("path cannot be empty")
ErrBusyTimeoutNegative = errors.New("busy_timeout must be >= 0")
ErrInvalidJournalMode = errors.New("invalid journal_mode")
ErrInvalidAutoVacuum = errors.New("invalid auto_vacuum")
ErrWALAutocheckpoint = errors.New("wal_autocheckpoint must be >= -1")
ErrInvalidSynchronous = errors.New("invalid synchronous")
)
const (
// DefaultBusyTimeout is the default busy timeout in milliseconds.
DefaultBusyTimeout = 10000
)
// JournalMode represents SQLite journal_mode pragma values.
// Journal modes control how SQLite handles write transactions and crash recovery.
//
// Performance vs Durability Tradeoffs:
//
// WAL (Write-Ahead Logging) - Recommended for production:
// - Best performance for concurrent reads/writes
// - Readers don't block writers, writers don't block readers
// - Excellent crash recovery with minimal data loss risk
// - Uses additional .wal and .shm files
// - Default choice for Headscale production deployments
//
// DELETE - Traditional rollback journal:
// - Good performance for single-threaded access
// - Readers block writers and vice versa
// - Reliable crash recovery but with exclusive locking
// - Creates temporary journal files during transactions
// - Suitable for low-concurrency scenarios
//
// TRUNCATE - Similar to DELETE but faster cleanup:
// - Slightly better performance than DELETE
// - Same concurrency limitations as DELETE
// - Faster transaction commit by truncating instead of deleting journal
//
// PERSIST - Journal file remains between transactions:
// - Avoids file creation/deletion overhead
// - Same concurrency limitations as DELETE
// - Good for frequent small transactions
//
// MEMORY - Journal kept in memory:
// - Fastest performance but NO crash recovery
// - Data loss risk on power failure or crash
// - Only suitable for temporary or non-critical data
//
// OFF - No journaling:
// - Maximum performance but NO transaction safety
// - High risk of database corruption on crash
// - Should only be used for read-only or disposable databases
type JournalMode string
const (
// JournalModeWAL enables Write-Ahead Logging (RECOMMENDED for production).
// Best concurrent performance + crash recovery. Uses additional .wal/.shm files.
JournalModeWAL JournalMode = "WAL"
// JournalModeDelete uses traditional rollback journaling.
// Good single-threaded performance, readers block writers. Creates temp journal files.
JournalModeDelete JournalMode = "DELETE"
// JournalModeTruncate is like DELETE but with faster cleanup.
// Slightly better performance than DELETE, same safety with exclusive locking.
JournalModeTruncate JournalMode = "TRUNCATE"
// JournalModePersist keeps journal file between transactions.
// Good for frequent transactions, avoids file creation/deletion overhead.
JournalModePersist JournalMode = "PERSIST"
// JournalModeMemory keeps journal in memory (DANGEROUS).
// Fastest performance but NO crash recovery - data loss on power failure.
JournalModeMemory JournalMode = "MEMORY"
// JournalModeOff disables journaling entirely (EXTREMELY DANGEROUS).
// Maximum performance but high corruption risk. Only for disposable databases.
JournalModeOff JournalMode = "OFF"
)
// IsValid returns true if the JournalMode is valid.
func (j JournalMode) IsValid() bool {
switch j {
case JournalModeWAL, JournalModeDelete, JournalModeTruncate,
JournalModePersist, JournalModeMemory, JournalModeOff:
return true
default:
return false
}
}
// String returns the string representation.
func (j JournalMode) String() string {
return string(j)
}
// AutoVacuum represents SQLite auto_vacuum pragma values.
// Auto-vacuum controls how SQLite reclaims space from deleted data.
//
// Performance vs Storage Tradeoffs:
//
// INCREMENTAL - Recommended for production:
// - Reclaims space gradually during normal operations
// - Minimal performance impact on writes
// - Database size shrinks automatically over time
// - Can manually trigger with PRAGMA incremental_vacuum
// - Good balance of space efficiency and performance
//
// FULL - Automatic space reclamation:
// - Immediately reclaims space on every DELETE/DROP
// - Higher write overhead due to page reorganization
// - Keeps database file size minimal
// - Can cause significant slowdowns on large deletions
// - Best for applications with frequent deletes and limited storage
//
// NONE - No automatic space reclamation:
// - Fastest write performance (no vacuum overhead)
// - Database file only grows, never shrinks
// - Deleted space is reused but file size remains large
// - Requires manual VACUUM to reclaim space
// - Best for write-heavy workloads where storage isn't constrained
type AutoVacuum string
const (
// AutoVacuumNone disables automatic space reclamation.
// Fastest writes, file only grows. Requires manual VACUUM to reclaim space.
AutoVacuumNone AutoVacuum = "NONE"
// AutoVacuumFull immediately reclaims space on every DELETE/DROP.
// Minimal file size but slower writes. Can impact performance on large deletions.
AutoVacuumFull AutoVacuum = "FULL"
// AutoVacuumIncremental reclaims space gradually (RECOMMENDED for production).
// Good balance: minimal write impact, automatic space management over time.
AutoVacuumIncremental AutoVacuum = "INCREMENTAL"
)
// IsValid returns true if the AutoVacuum is valid.
func (a AutoVacuum) IsValid() bool {
switch a {
case AutoVacuumNone, AutoVacuumFull, AutoVacuumIncremental:
return true
default:
return false
}
}
// String returns the string representation.
func (a AutoVacuum) String() string {
return string(a)
}
// Synchronous represents SQLite synchronous pragma values.
// Synchronous mode controls how aggressively SQLite flushes data to disk.
//
// Performance vs Durability Tradeoffs:
//
// NORMAL - Recommended for production:
// - Good balance of performance and safety
// - Syncs at critical moments (transaction commits in WAL mode)
// - Very low risk of corruption, minimal performance impact
// - Safe with WAL mode even with power loss
// - Default choice for most production applications
//
// FULL - Maximum durability:
// - Syncs to disk after every write operation
// - Highest data safety, virtually no corruption risk
// - Significant performance penalty (up to 50% slower)
// - Recommended for critical data where corruption is unacceptable
//
// EXTRA - Paranoid mode:
// - Even more aggressive syncing than FULL
// - Maximum possible data safety
// - Severe performance impact
// - Only for extremely critical scenarios
//
// OFF - Maximum performance, minimum safety:
// - No syncing, relies on OS to flush data
// - Fastest possible performance
// - High risk of corruption on power failure or crash
// - Only suitable for non-critical or easily recreatable data
type Synchronous string
const (
// SynchronousOff disables syncing (DANGEROUS).
// Fastest performance but high corruption risk on power failure. Avoid in production.
SynchronousOff Synchronous = "OFF"
// SynchronousNormal provides balanced performance and safety (RECOMMENDED).
// Good performance with low corruption risk. Safe with WAL mode on power loss.
SynchronousNormal Synchronous = "NORMAL"
// SynchronousFull provides maximum durability with performance cost.
// Syncs after every write. Up to 50% slower but virtually no corruption risk.
SynchronousFull Synchronous = "FULL"
// SynchronousExtra provides paranoid-level data safety (EXTREME).
// Maximum safety with severe performance impact. Rarely needed in practice.
SynchronousExtra Synchronous = "EXTRA"
)
// IsValid returns true if the Synchronous is valid.
func (s Synchronous) IsValid() bool {
switch s {
case SynchronousOff, SynchronousNormal, SynchronousFull, SynchronousExtra:
return true
default:
return false
}
}
// String returns the string representation.
func (s Synchronous) String() string {
return string(s)
}
// Config holds SQLite database configuration with type-safe enums.
// This configuration balances performance, durability, and operational requirements
// for Headscale's SQLite database usage patterns.
type Config struct {
Path string // file path or ":memory:"
BusyTimeout int // milliseconds (0 = default/disabled)
JournalMode JournalMode // journal mode (affects concurrency and crash recovery)
AutoVacuum AutoVacuum // auto vacuum mode (affects storage efficiency)
WALAutocheckpoint int // pages (-1 = default/not set, 0 = disabled, >0 = enabled)
Synchronous Synchronous // synchronous mode (affects durability vs performance)
ForeignKeys bool // enable foreign key constraints (data integrity)
}
// Default returns the production configuration optimized for Headscale's usage patterns.
// This configuration prioritizes:
// - Concurrent access (WAL mode for multiple readers/writers)
// - Data durability with good performance (NORMAL synchronous)
// - Automatic space management (INCREMENTAL auto-vacuum)
// - Data integrity (foreign key constraints enabled)
// - Reasonable timeout for busy database scenarios (10s)
func Default(path string) *Config {
return &Config{
Path: path,
BusyTimeout: DefaultBusyTimeout,
JournalMode: JournalModeWAL,
AutoVacuum: AutoVacuumIncremental,
WALAutocheckpoint: 1000,
Synchronous: SynchronousNormal,
ForeignKeys: true,
}
}
// Memory returns a configuration for in-memory databases.
func Memory() *Config {
return &Config{
Path: ":memory:",
WALAutocheckpoint: -1, // not set, use driver default
ForeignKeys: true,
}
}
// Validate checks if all configuration values are valid.
func (c *Config) Validate() error {
if c.Path == "" {
return ErrPathEmpty
}
if c.BusyTimeout < 0 {
return fmt.Errorf("%w, got %d", ErrBusyTimeoutNegative, c.BusyTimeout)
}
if c.JournalMode != "" && !c.JournalMode.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidJournalMode, c.JournalMode)
}
if c.AutoVacuum != "" && !c.AutoVacuum.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidAutoVacuum, c.AutoVacuum)
}
if c.WALAutocheckpoint < -1 {
return fmt.Errorf("%w, got %d", ErrWALAutocheckpoint, c.WALAutocheckpoint)
}
if c.Synchronous != "" && !c.Synchronous.IsValid() {
return fmt.Errorf("%w: %s", ErrInvalidSynchronous, c.Synchronous)
}
return nil
}
// ToURL builds a properly encoded SQLite connection string using _pragma parameters
// compatible with modernc.org/sqlite driver.
func (c *Config) ToURL() (string, error) {
if err := c.Validate(); err != nil {
return "", fmt.Errorf("invalid config: %w", err)
}
var pragmas []string
// Add pragma parameters only if they're set (non-zero/non-empty)
if c.BusyTimeout > 0 {
pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout))
}
if c.JournalMode != "" {
pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode))
}
if c.AutoVacuum != "" {
pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum))
}
if c.WALAutocheckpoint >= 0 {
pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint))
}
if c.Synchronous != "" {
pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous))
}
if c.ForeignKeys {
pragmas = append(pragmas, "foreign_keys=ON")
}
// Handle different database types
var baseURL string
if c.Path == ":memory:" {
baseURL = ":memory:"
} else {
baseURL = "file:" + c.Path
}
// Add parameters without encoding = signs
if len(pragmas) > 0 {
var queryParts []string
for _, pragma := range pragmas {
queryParts = append(queryParts, "_pragma="+pragma)
}
baseURL += "?" + strings.Join(queryParts, "&")
}
return baseURL, nil
}

View File

@@ -0,0 +1,211 @@
package sqliteconfig
import (
"testing"
)
func TestJournalMode(t *testing.T) {
tests := []struct {
mode JournalMode
valid bool
}{
{JournalModeWAL, true},
{JournalModeDelete, true},
{JournalModeTruncate, true},
{JournalModePersist, true},
{JournalModeMemory, true},
{JournalModeOff, true},
{JournalMode("INVALID"), false},
{JournalMode(""), false},
}
for _, tt := range tests {
t.Run(string(tt.mode), func(t *testing.T) {
if got := tt.mode.IsValid(); got != tt.valid {
t.Errorf("JournalMode(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
}
})
}
}
func TestAutoVacuum(t *testing.T) {
tests := []struct {
mode AutoVacuum
valid bool
}{
{AutoVacuumNone, true},
{AutoVacuumFull, true},
{AutoVacuumIncremental, true},
{AutoVacuum("INVALID"), false},
{AutoVacuum(""), false},
}
for _, tt := range tests {
t.Run(string(tt.mode), func(t *testing.T) {
if got := tt.mode.IsValid(); got != tt.valid {
t.Errorf("AutoVacuum(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
}
})
}
}
func TestSynchronous(t *testing.T) {
tests := []struct {
mode Synchronous
valid bool
}{
{SynchronousOff, true},
{SynchronousNormal, true},
{SynchronousFull, true},
{SynchronousExtra, true},
{Synchronous("INVALID"), false},
{Synchronous(""), false},
}
for _, tt := range tests {
t.Run(string(tt.mode), func(t *testing.T) {
if got := tt.mode.IsValid(); got != tt.valid {
t.Errorf("Synchronous(%q).IsValid() = %v, want %v", tt.mode, got, tt.valid)
}
})
}
}
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "valid default config",
config: Default("/path/to/db.sqlite"),
},
{
name: "empty path",
config: &Config{
Path: "",
},
wantErr: true,
},
{
name: "negative busy timeout",
config: &Config{
Path: "/path/to/db.sqlite",
BusyTimeout: -1,
},
wantErr: true,
},
{
name: "invalid journal mode",
config: &Config{
Path: "/path/to/db.sqlite",
JournalMode: JournalMode("INVALID"),
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestConfigToURL(t *testing.T) {
tests := []struct {
name string
config *Config
want string
}{
{
name: "default config",
config: Default("/path/to/db.sqlite"),
want: "file:/path/to/db.sqlite?_pragma=busy_timeout=10000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=INCREMENTAL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=NORMAL&_pragma=foreign_keys=ON",
},
{
name: "memory config",
config: Memory(),
want: ":memory:?_pragma=foreign_keys=ON",
},
{
name: "minimal config",
config: &Config{
Path: "/simple/db.sqlite",
WALAutocheckpoint: -1, // not set
},
want: "file:/simple/db.sqlite",
},
{
name: "custom config",
config: &Config{
Path: "/custom/db.sqlite",
BusyTimeout: 5000,
JournalMode: JournalModeDelete,
WALAutocheckpoint: -1, // not set
Synchronous: SynchronousFull,
ForeignKeys: true,
},
want: "file:/custom/db.sqlite?_pragma=busy_timeout=5000&_pragma=journal_mode=DELETE&_pragma=synchronous=FULL&_pragma=foreign_keys=ON",
},
{
name: "memory with custom timeout",
config: &Config{
Path: ":memory:",
BusyTimeout: 2000,
WALAutocheckpoint: -1, // not set
ForeignKeys: true,
},
want: ":memory:?_pragma=busy_timeout=2000&_pragma=foreign_keys=ON",
},
{
name: "wal autocheckpoint zero",
config: &Config{
Path: "/test.db",
WALAutocheckpoint: 0,
},
want: "file:/test.db?_pragma=wal_autocheckpoint=0",
},
{
name: "all options",
config: &Config{
Path: "/full.db",
BusyTimeout: 15000,
JournalMode: JournalModeWAL,
AutoVacuum: AutoVacuumFull,
WALAutocheckpoint: 1000,
Synchronous: SynchronousExtra,
ForeignKeys: true,
},
want: "file:/full.db?_pragma=busy_timeout=15000&_pragma=journal_mode=WAL&_pragma=auto_vacuum=FULL&_pragma=wal_autocheckpoint=1000&_pragma=synchronous=EXTRA&_pragma=foreign_keys=ON",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.config.ToURL()
if err != nil {
t.Errorf("Config.ToURL() error = %v", err)
return
}
if got != tt.want {
t.Errorf("Config.ToURL() = %q, want %q", got, tt.want)
}
})
}
}
func TestConfigToURLInvalid(t *testing.T) {
config := &Config{
Path: "",
BusyTimeout: -1,
}
_, err := config.ToURL()
if err == nil {
t.Error("Config.ToURL() with invalid config should return error")
}
}

View File

@@ -0,0 +1,269 @@
package sqliteconfig
import (
"database/sql"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
)
const memoryDBPath = ":memory:"
// TestSQLiteDriverPragmaIntegration verifies that the modernc.org/sqlite driver
// correctly applies all pragma settings from URL parameters, ensuring they work
// the same as the old SQL PRAGMA statements approach.
func TestSQLiteDriverPragmaIntegration(t *testing.T) {
tests := []struct {
name string
config *Config
expected map[string]any
}{
{
name: "default configuration",
config: Default("/tmp/test.db"),
expected: map[string]any{
"busy_timeout": 10000,
"journal_mode": "wal",
"auto_vacuum": 2, // INCREMENTAL = 2
"wal_autocheckpoint": 1000,
"synchronous": 1, // NORMAL = 1
"foreign_keys": 1, // ON = 1
},
},
{
name: "memory database with foreign keys",
config: Memory(),
expected: map[string]any{
"foreign_keys": 1, // ON = 1
},
},
{
name: "custom configuration",
config: &Config{
Path: "/tmp/custom.db",
BusyTimeout: 5000,
JournalMode: JournalModeDelete,
AutoVacuum: AutoVacuumFull,
WALAutocheckpoint: 1000,
Synchronous: SynchronousFull,
ForeignKeys: true,
},
expected: map[string]any{
"busy_timeout": 5000,
"journal_mode": "delete",
"auto_vacuum": 1, // FULL = 1
"wal_autocheckpoint": 1000,
"synchronous": 2, // FULL = 2
"foreign_keys": 1, // ON = 1
},
},
{
name: "foreign keys disabled",
config: &Config{
Path: "/tmp/no_fk.db",
ForeignKeys: false,
},
expected: map[string]any{
// foreign_keys should not be set (defaults to 0/OFF)
"foreign_keys": 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary database file if not memory
if tt.config.Path == memoryDBPath {
// For memory databases, no changes needed
} else {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test.db")
// Update config with actual temp path
configCopy := *tt.config
configCopy.Path = dbPath
tt.config = &configCopy
}
// Generate URL and open database
url, err := tt.config.ToURL()
if err != nil {
t.Fatalf("Failed to generate URL: %v", err)
}
t.Logf("Opening database with URL: %s", url)
db, err := sql.Open("sqlite", url)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
t.Fatalf("Failed to ping database: %v", err)
}
// Verify each expected pragma setting
for pragma, expectedValue := range tt.expected {
t.Run("pragma_"+pragma, func(t *testing.T) {
var actualValue any
query := "PRAGMA " + pragma
err := db.QueryRow(query).Scan(&actualValue)
if err != nil {
t.Fatalf("Failed to query %s: %v", query, err)
}
t.Logf("%s: expected=%v, actual=%v", pragma, expectedValue, actualValue)
// Handle type conversion for comparison
switch expected := expectedValue.(type) {
case int:
if actual, ok := actualValue.(int64); ok {
if int64(expected) != actual {
t.Errorf("%s: expected %d, got %d", pragma, expected, actual)
}
} else {
t.Errorf("%s: expected int %d, got %T %v", pragma, expected, actualValue, actualValue)
}
case string:
if actual, ok := actualValue.(string); ok {
if expected != actual {
t.Errorf("%s: expected %q, got %q", pragma, expected, actual)
}
} else {
t.Errorf("%s: expected string %q, got %T %v", pragma, expected, actualValue, actualValue)
}
default:
t.Errorf("Unsupported expected type for %s: %T", pragma, expectedValue)
}
})
}
})
}
}
// TestForeignKeyConstraintEnforcement verifies that foreign key constraints
// are actually enforced when enabled via URL parameters.
func TestForeignKeyConstraintEnforcement(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "fk_test.db")
config := Default(dbPath)
url, err := config.ToURL()
if err != nil {
t.Fatalf("Failed to generate URL: %v", err)
}
db, err := sql.Open("sqlite", url)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create test tables with foreign key relationship
schema := `
CREATE TABLE parent (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE child (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (parent_id) REFERENCES parent(id)
);
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create schema: %v", err)
}
// Insert parent record
if _, err := db.Exec("INSERT INTO parent (id, name) VALUES (1, 'Parent 1')"); err != nil {
t.Fatalf("Failed to insert parent: %v", err)
}
// Test 1: Valid foreign key should work
_, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (1, 1, 'Child 1')")
if err != nil {
t.Fatalf("Valid foreign key insert failed: %v", err)
}
// Test 2: Invalid foreign key should fail
_, err = db.Exec("INSERT INTO child (id, parent_id, name) VALUES (2, 999, 'Child 2')")
if err == nil {
t.Error("Expected foreign key constraint violation, but insert succeeded")
} else if !contains(err.Error(), "FOREIGN KEY constraint failed") {
t.Errorf("Expected foreign key constraint error, got: %v", err)
} else {
t.Logf("✓ Foreign key constraint correctly enforced: %v", err)
}
// Test 3: Deleting referenced parent should fail
_, err = db.Exec("DELETE FROM parent WHERE id = 1")
if err == nil {
t.Error("Expected foreign key constraint violation when deleting referenced parent")
} else if !contains(err.Error(), "FOREIGN KEY constraint failed") {
t.Errorf("Expected foreign key constraint error on delete, got: %v", err)
} else {
t.Logf("✓ Foreign key constraint correctly prevented parent deletion: %v", err)
}
}
// TestJournalModeValidation verifies that the journal_mode setting is applied correctly.
func TestJournalModeValidation(t *testing.T) {
modes := []struct {
mode JournalMode
expected string
}{
{JournalModeWAL, "wal"},
{JournalModeDelete, "delete"},
{JournalModeTruncate, "truncate"},
{JournalModeMemory, "memory"},
}
for _, tt := range modes {
t.Run(string(tt.mode), func(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "journal_test.db")
config := &Config{
Path: dbPath,
JournalMode: tt.mode,
ForeignKeys: true,
}
url, err := config.ToURL()
if err != nil {
t.Fatalf("Failed to generate URL: %v", err)
}
db, err := sql.Open("sqlite", url)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
var actualMode string
err = db.QueryRow("PRAGMA journal_mode").Scan(&actualMode)
if err != nil {
t.Fatalf("Failed to query journal_mode: %v", err)
}
if actualMode != tt.expected {
t.Errorf("journal_mode: expected %q, got %q", tt.expected, actualMode)
} else {
t.Logf("✓ journal_mode correctly set to: %s", actualMode)
}
})
}
}
// contains checks if a string contains a substring (helper function).
func contains(str, substr string) bool {
return strings.Contains(str, substr)
}