internal/storage: SQLite-БД для задач и трассировок (таски + статус-машина, трассы с группировкой по task_id)
All checks were successful
build-test / build (push) Successful in 30s
All checks were successful
build-test / build (push) Successful in 30s
This commit is contained in:
158
internal/storage/storage.go
Normal file
158
internal/storage/storage.go
Normal file
@@ -0,0 +1,158 @@
|
||||
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)
|
||||
}
|
||||
// Прагмы: 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 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())
|
||||
}
|
||||
Reference in New Issue
Block a user