package store import ( "database/sql" "embed" "fmt" "log/slog" "strings" "time" _ "modernc.org/sqlite" // درایور SQLite خالص Go (بدون cgo) ) //go:embed migrations/*.sql var migrationsFS embed.FS // Store دسترسی به دیتابیس را کپسوله می‌کند. type Store struct { DB *sql.DB } // Open اتصال SQLite را باز کرده و migrations را اجرا می‌کند. func Open(path string) (*Store, error) { // pragmaها برای کارایی و یکپارچگی روی سرور کوچک. busy_timeout بالاتر تا نویسنده‌ی // دوم به‌جای خطای «database is locked» چند لحظه منتظر بماند. dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(ON)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } // WAL اجازه می‌دهد چند «خواننده» هم‌زمان باشند و فقط «نویسنده» تک بماند. با // SetMaxOpenConns(1) این مزیت هدر می‌رفت (همه، حتی خواندن‌ها، در صفِ تک‌اتصالی). // pool بزرگ‌تر ⇒ خواندن‌های هم‌زمان بدونِ صف؛ نوشتن‌ها با busy_timeout سریالی می‌مانند. db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) db.SetConnMaxIdleTime(5 * time.Minute) if err := db.Ping(); err != nil { return nil, err } s := &Store{DB: db} if err := s.migrate(); err != nil { return nil, err } return s, nil } func (s *Store) migrate() error { // جدول ردیابی تا هر migration فقط یک‌بار اجرا شود. if _, err := s.DB.Exec( `CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )`); err != nil { return fmt.Errorf("create schema_migrations: %w", err) } entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) } for _, e := range entries { if e.IsDir() { continue } name := e.Name() var applied int if err := s.DB.QueryRow( `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, name).Scan(&applied); err != nil { return err } if applied > 0 { continue } b, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return err } if _, err := s.DB.Exec(string(b)); err != nil { // دیتابیس توسعه‌ای که قبل از افزودن ردیابی، این migration را اعمال کرده // (ستون/جدول از قبل هست) ⇒ به‌عنوان اعمال‌شده علامت می‌زنیم. if alreadyApplied(err) { slog.Warn("migration schema already present; marking applied", "name", name) } else { return fmt.Errorf("apply %s: %w", name, err) } } if _, err := s.DB.Exec( `INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil { return fmt.Errorf("record %s: %w", name, err) } } return nil } // alreadyApplied تشخیص می‌دهد خطا ناشی از وجود قبلی همان تغییر است. func alreadyApplied(err error) bool { m := strings.ToLower(err.Error()) return strings.Contains(m, "duplicate column") || strings.Contains(m, "already exists") } func (s *Store) Close() error { return s.DB.Close() }