all repos — weimar @ fe3bf26c76d0c554ef9e8f0c9f52038b37ea886e

Unnamed repository; edit this file 'description' to name the repository.

internal/db/sqlite.go (view raw)

  1package db
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"fmt"
  7	"time"
  8
  9	_ "modernc.org/sqlite"
 10)
 11
 12// DB wraps a *sql.DB connection to a SQLite database.
 13type DB struct {
 14	db *sql.DB
 15}
 16
 17// Open opens (or creates) a SQLite database at the given path.
 18func Open(path string) (*DB, error) {
 19	db, err := sql.Open("sqlite", path)
 20	if err != nil {
 21		return nil, fmt.Errorf("opening database: %w", err)
 22	}
 23
 24	// SQLite is safe with a single connection for reads and serialises writes.
 25	db.SetMaxOpenConns(1)
 26	db.SetMaxIdleConns(1)
 27	db.SetConnMaxLifetime(0)
 28
 29	return &DB{db: db}, nil
 30}
 31
 32// Close closes the database connection.
 33func (d *DB) Close() error {
 34	return d.db.Close()
 35}
 36
 37// DB returns the underlying *sql.DB for use in query methods.
 38func (d *DB) DB() *sql.DB {
 39	return d.db
 40}
 41
 42// Migrate runs all pending schema migrations.
 43func (d *DB) Migrate(ctx context.Context) error {
 44	_, err := d.db.ExecContext(ctx, `
 45		CREATE TABLE IF NOT EXISTS schema_migrations (
 46			version INTEGER PRIMARY KEY,
 47			applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
 48		)
 49	`)
 50	if err != nil {
 51		return fmt.Errorf("creating migrations table: %w", err)
 52	}
 53
 54	migrations := []struct {
 55		version int
 56		sql     string
 57	}{
 58		{
 59			version: 1,
 60			sql: `
 61				CREATE TABLE IF NOT EXISTS users (
 62					id            INTEGER PRIMARY KEY AUTOINCREMENT,
 63					username      TEXT    UNIQUE NOT NULL,
 64					password_hash TEXT    NOT NULL,
 65					is_admin      INTEGER DEFAULT 0,
 66					created_at    TEXT    NOT NULL,
 67					updated_at    TEXT    NOT NULL
 68				)
 69			`,
 70		},
 71		{
 72			version: 2,
 73			sql: `
 74				CREATE TABLE IF NOT EXISTS sessions (
 75					id         INTEGER PRIMARY KEY AUTOINCREMENT,
 76					user_id    INTEGER NOT NULL REFERENCES users(id),
 77					token      TEXT    UNIQUE NOT NULL,
 78					expires_at TEXT    NOT NULL,
 79					created_at TEXT    NOT NULL
 80				)
 81			`,
 82		},
 83		{
 84			version: 3,
 85			sql: `
 86				CREATE TABLE IF NOT EXISTS images (
 87					id             INTEGER PRIMARY KEY AUTOINCREMENT,
 88					filename       TEXT    NOT NULL,
 89					storage_path   TEXT    NOT NULL,
 90					thumbnail_path TEXT,
 91					uploaded_by    INTEGER NOT NULL REFERENCES users(id),
 92					file_size      INTEGER NOT NULL,
 93					width          INTEGER NOT NULL,
 94					height         INTEGER NOT NULL,
 95					mime_type      TEXT    NOT NULL,
 96					created_at     TEXT    NOT NULL,
 97					updated_at     TEXT    NOT NULL
 98				)
 99			`,
100		},
101		{
102			version: 4,
103			sql: `
104				CREATE TABLE IF NOT EXISTS image_tags (
105					image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
106					tag      TEXT    NOT NULL,
107					PRIMARY KEY (image_id, tag)
108				)
109			`,
110		},
111		{
112			version: 5,
113			sql: `
114				CREATE TABLE IF NOT EXISTS tags (
115					tag         TEXT PRIMARY KEY,
116					usage_count INTEGER DEFAULT 0
117				)
118			`,
119		},
120		{
121			version: 6,
122			sql: `
123				CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
124				CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
125				CREATE INDEX IF NOT EXISTS idx_images_uploaded_by ON images(uploaded_by);
126				CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);
127				CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
128			`,
129		},
130		{
131			version: 7,
132			sql: `
133				ALTER TABLE users ADD COLUMN avatar_path TEXT;
134			`,
135		},
136	}
137
138	for _, m := range migrations {
139		var count int
140		err := d.db.QueryRowContext(ctx,
141			"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version,
142		).Scan(&count)
143		if err != nil {
144			return fmt.Errorf("checking migration %d: %w", m.version, err)
145		}
146		if count > 0 {
147			continue
148		}
149
150		if _, err := d.db.ExecContext(ctx, m.sql); err != nil {
151			return fmt.Errorf("running migration %d: %w", m.version, err)
152		}
153
154		if _, err := d.db.ExecContext(ctx,
155			"INSERT INTO schema_migrations (version) VALUES (?)", m.version,
156		); err != nil {
157			return fmt.Errorf("recording migration %d: %w", m.version, err)
158		}
159	}
160
161	return nil
162}
163
164// now returns the current UTC time formatted as ISO 8601 (RFC 3339).
165func now() string {
166	return time.Now().UTC().Format(time.RFC3339)
167}
168
169// scanTime parses an ISO 8601 string into time.Time.
170func scanTime(s string) (time.Time, error) {
171	return time.Parse(time.RFC3339, s)
172}