// Package db provides SQLite persistence for odidere. package db import ( "context" "database/sql" "fmt" "regexp" "sync" "uuid" "github.com/mattn/go-sqlite3" ) // Config holds database connection settings. type Config struct { Path string } // SQLite holds the reader and writer connection pools. type SQLite struct { Writer *sql.DB Reader *sql.DB } var driverOnce sync.Once // Connect opens a SQLite database with dual connection pools. // // Two connection pools are used: a single-connection writer and a // multi-connection reader. This follows the recommended pattern for // SQLite in WAL mode with Go's database/sql. // // The writer pool is limited to one connection. Go serializes // access in-process, so SQLite never sees concurrent write // attempts. This eliminates SQLITE_BUSY on writes without needing // a busy_timeout. // // The reader pool allows concurrent reads, which WAL mode supports // without blocking the writer or each other. func Connect(ctx context.Context, cfg Config) (*SQLite, error) { // Ensure timestamps default to RFC3339-compatible format. sqlite3.SQLiteTimestampFormats = []string{ "2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05.999999999-07:00", "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05.999999999", "2006-01-02T15:04:05.999999999", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02T15:04", "2006-01-02", } driverOnce.Do(func() { sql.Register("odidere_sqlite", &sqlite3.SQLiteDriver{ ConnectHook: func(conn *sqlite3.SQLiteConn) error { if err := conn.RegisterFunc( "uuid4", func() string { return uuid.New().String() }, false, ); err != nil { return fmt.Errorf("failed to register uuid4: %w", err) } if err := conn.RegisterFunc( "regexp", func(re, s string) (bool, error) { return regexp.MatchString(re, s) }, true, ); err != nil { return fmt.Errorf("failed to register regexp: %w", err) } return nil }, }) }) // Writer DSN. // _busy_timeout=5000: wait up to 5 seconds if we encounter // a lock. Rare in WAL mode, but possible during shutdown cleanup // or crash recovery (see sqlite.org/wal.html §9). // _cache_size=-16000: 16 MB page cache. Reduces disk reads for // the single write connection that handles all mutations. // _foreign_keys=on: enforce foreign key constraints. // _journal_mode=WAL: write-ahead logging. Allows concurrent // readers while writing. // _synchronous=1: NORMAL durability. WAL mode ensures consistency // even with NORMAL. Avoids the performance cost of FULL // (fsync every commit). // _txlock=immediate: acquire the write lock at BEGIN, not at the // first write statement. Prevents deadlocks when multiple // connections attempt deferred → write lock escalation. writerDSN := "file:" + cfg.Path + "?" + "_busy_timeout=5000&" + "_cache_size=-16000&" + "_foreign_keys=on&" + "_journal_mode=WAL&" + "_loc=UTC&" + "_synchronous=1&" + "_txlock=immediate" w, err := sql.Open("odidere_sqlite", writerDSN) if err != nil { return nil, fmt.Errorf( "failed to open sqlite writer: %v", err, ) } // One connection: all writes serialize in-process via the pool. w.SetMaxOpenConns(1) w.SetMaxIdleConns(1) if err := w.PingContext(ctx); err != nil { return nil, fmt.Errorf( "failed to ping sqlite writer: %v", err, ) } // Reader DSN. // mode=ro: open read-only. Prevents accidental writes through // the reader pool. // _busy_timeout=5000: wait up to 5 seconds if a read encounters // a lock. Rare in WAL mode, but possible during shutdown cleanup // or crash recovery (see sqlite.org/wal.html §9). // _cache_size=-8000: 8 MB page cache per connection. Smaller than // the writer since there are multiple reader connections. // _journal_mode=WAL: must match the writer. WAL is persistent on // the file, but setting it here ensures the connection is // configured correctly if the database is new. readerDSN := "file:" + cfg.Path + "?" + "_busy_timeout=5000&" + "_cache_size=-8000&" + "_journal_mode=WAL&" + "_loc=UTC&" + "mode=ro" r, err := sql.Open("odidere_sqlite", readerDSN) if err != nil { return nil, fmt.Errorf( "failed to open sqlite reader: %v", err, ) } r.SetMaxOpenConns(2) r.SetMaxIdleConns(2) if err := r.PingContext(ctx); err != nil { return nil, fmt.Errorf( "failed to ping sqlite reader: %v", err, ) } // Keep the connections open. w.SetConnMaxLifetime(0) w.SetConnMaxIdleTime(0) r.SetConnMaxLifetime(0) r.SetConnMaxIdleTime(0) return &SQLite{Reader: r, Writer: w}, nil } // Close shuts down both connection pools. func (s *SQLite) Close() error { if err := s.Writer.Close(); err != nil { return fmt.Errorf("failed to close writer: %w", err) } if err := s.Reader.Close(); err != nil { return fmt.Errorf("failed to close reader: %w", err) } return nil }