// Package sqlite implements the storage interfaces on modernc.org/sqlite, // a pure-Go SQLite with no cgo. It is the development and single-user // driver; PostgreSQL is the production one. package sqlite import ( "context" "database/sql" "embed" "fmt" "log/slog" "net/url" "os" "path/filepath" "strings" _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/storage/migrations" "github.com/dariuszjaniak/warp/server/internal/storage/sqlstore" ) //go:embed migrations/*.sql var migrationFiles embed.FS func init() { storage.Register(storage.DriverSQLite, Open) } // dialect is the migration bookkeeping for SQLite. var dialect = migrations.Dialect{ Name: storage.DriverSQLite, CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TIMESTAMP NOT NULL )`, SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, } var settingsQueries = sqlstore.SettingsQueries{ Get: `SELECT setting_value FROM server_settings WHERE setting_key = ?`, GetAll: `SELECT setting_key, setting_value FROM server_settings`, Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES (?, ?, ?) ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`, Delete: `DELETE FROM server_settings WHERE setting_key = ?`, } // searchDialect is the full-text index on FTS5. The table cannot be // aliased — MATCH and bm25() take its own name — and its rowid is the // event's stream ordering. bm25() scores better matches lower, so the rank // is its negation. var searchDialect = &sqlstore.SearchDialect{ Insert: `INSERT INTO roomserver_event_search (rowid, room_id, event_id, search_key, sender, event_type, body) VALUES (?, ?, ?, ?, ?, ?, ?)`, DeleteEvent: `DELETE FROM roomserver_event_search WHERE rowid IN ( SELECT stream_ordering FROM roomserver_events WHERE room_id = ? AND event_id = ?)`, DeleteRoom: `DELETE FROM roomserver_event_search WHERE room_id = ?`, From: `roomserver_event_search`, Name: `roomserver_event_search`, Position: `roomserver_event_search.rowid`, Match: `roomserver_event_search MATCH ?`, Rank: `-bm25(roomserver_event_search)`, Query: ftsQuery, } // ftsQuery builds an FTS5 query from a search term: every word, quoted so // that nothing in it is read as an operator, and matched as a prefix so // that a user who has typed "hel" already finds "hello". Words side by // side are ANDed. func ftsQuery(term string) string { words := storage.SearchWords(term) for i, w := range words { words[i] = `"` + strings.ReplaceAll(w, `"`, `""`) + `"*` } return strings.Join(words, " ") } // StoreOptions are the sqlstore options for a SQLite database already // opened on db: the migrations, statements and error recognition every // SQLite-backed driver shares. The PocketBase driver runs on a SQLite file // of its own and uses them as they are. func StoreOptions(db *sql.DB, log *slog.Logger) sqlstore.Options { return sqlstore.Options{ DriverName: storage.DriverSQLite, DB: db, Dialect: dialect, MigrationsFS: migrationFiles, MigrationsDir: "migrations", Settings: settingsQueries, // SQLite already uses the `?` placeholders the shared component // stores are written with, so there is nothing to rebind. IsUniqueViolation: isUniqueViolation, Search: searchDialect, Logger: log, } } // Database is the SQLite implementation of storage.Database. type Database struct { *sqlstore.Store } // Open connects to the SQLite file named by cfg.ConnectionString. // // The connection string is a modernc.org/sqlite DSN: "file:warp.db" with // optional "_pragma=..." parameters. The parent directory is created if it // does not exist, because a missing directory is the single most common // first-run failure. // // Writes are serialised onto one connection: SQLite allows a single writer, // and letting database/sql open several write connections turns that into // SQLITE_BUSY under concurrency rather than a queue. func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) { dsn := cfg.ConnectionString if dsn == "" { return nil, fmt.Errorf("sqlite: connection_string must not be empty") } if err := ensureParentDir(dsn); err != nil { return nil, err } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) db.SetConnMaxLifetime(cfg.ConnMaxLifetime) if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite: connect: %w", err) } store, err := sqlstore.New(StoreOptions(db, log)) if err != nil { _ = db.Close() return nil, err } return &Database{Store: store}, nil } // ensureParentDir creates the directory holding the database file. In-memory // databases (":memory:" or "mode=memory") have no directory. func ensureParentDir(dsn string) error { path := filePathFromDSN(dsn) if path == "" { return nil } dir := filepath.Dir(path) if dir == "" || dir == "." { return nil } if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("sqlite: create database directory %s: %w", dir, err) } return nil } // filePathFromDSN extracts the filesystem path from a SQLite DSN, or "" for // an in-memory database. func filePathFromDSN(dsn string) string { path := strings.TrimPrefix(dsn, "file:") if i := strings.IndexAny(path, "?"); i >= 0 { path = path[:i] } if unescaped, err := url.PathUnescape(path); err == nil { path = unescaped } if path == "" || path == ":memory:" || strings.Contains(dsn, "mode=memory") { return "" } return path }