Files
ratatoskr-go/internal/storage/storage.go
2026-08-15 09:14:34 +05:00

171 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// SQLiteTime — time.Time с sql.Scanner и driver.Valuer для modernc.org/sqlite.
// Хранит как INTEGER (Unix-секунды).
type SQLiteTime time.Time
func (st *SQLiteTime) Scan(src any) error {
if src == nil {
*st = SQLiteTime(time.Time{})
return nil
}
switch v := src.(type) {
case int64:
*st = SQLiteTime(time.Unix(v, 0).UTC())
return nil
case float64:
*st = SQLiteTime(time.Unix(int64(v), 0).UTC())
return nil
case string:
// fallback: RFC3339
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return fmt.Errorf("parse time %q: %w", v, err)
}
*st = SQLiteTime(t)
return nil
default:
return fmt.Errorf("cannot scan %T as SQLiteTime", src)
}
}
func (st SQLiteTime) Value() (driver.Value, error) {
return time.Time(st).Unix(), nil
}
func (st SQLiteTime) Time() time.Time {
return time.Time(st)
}
// NullSQLiteTime — nullable версия SQLiteTime.
type NullSQLiteTime struct {
Time SQLiteTime
Valid bool
}
func (n *NullSQLiteTime) Scan(src any) error {
if src == nil {
n.Valid = false
return nil
}
n.Valid = true
return n.Time.Scan(src)
}
func (n NullSQLiteTime) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
return n.Time.Value()
}
// Storage — компонент БД для задач и трассировок.
type Storage struct {
db *sql.DB
}
// Open открывает SQLite-БД и выполняет миграции.
// path — путь к файлу БД; ":memory:" для тестов.
func Open(ctx context.Context, path string) (*Storage, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("%w: open: %w", ErrDB, err)
}
// Один connection — сериализует доступ, исключает SQLITE_BUSY
// при конкурентной записи из нескольких горутин (worker).
db.SetMaxOpenConns(1)
// Прагмы: WAL + синхронность
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
"PRAGMA foreign_keys=ON",
}
for _, p := range pragmas {
if _, err := db.ExecContext(ctx, p); err != nil {
db.Close()
return nil, fmt.Errorf("%w: pragma %q: %w", ErrDB, p, err)
}
}
s := &Storage{db: db}
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close закрывает БД.
func (s *Storage) Close() error {
return s.db.Close()
}
// DB возвращает сырой *sql.DB для использования в транзакциях.
func (s *Storage) DB() *sql.DB {
return s.db
}
// migrate создаёт таблицы при первом запуске.
func (s *Storage) migrate(ctx context.Context) error {
schema := `
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
goal TEXT NOT NULL DEFAULT '',
repo TEXT NOT NULL DEFAULT '',
why TEXT NOT NULL DEFAULT '',
ac TEXT NOT NULL DEFAULT '',
task_tag TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_tasks_chat ON tasks(chat_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_history_task ON task_history(task_id);
CREATE TABLE IF NOT EXISTS traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
agent TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL DEFAULT (unixepoch()),
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_traces_task ON traces(task_id);
`
if _, err := s.db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("%w: migrate: %w", ErrDB, err)
}
return nil
}
// Now возвращает текущее время UTC как SQLiteTime.
func Now() SQLiteTime {
return SQLiteTime(time.Now().UTC())
}