- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
// Package store — SQLite-витрина healthlog: доставки и (позже) разобранные
|
|
// точки. Витрина производна от сырого архива и пересобирается из него.
|
|
package store
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/pressly/goose/v3"
|
|
|
|
_ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// Store — доступ к витрине.
|
|
type Store struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// Open открывает БД по пути и накатывает миграции.
|
|
func Open(dbPath string) (*Store, error) {
|
|
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
|
}
|
|
|
|
if err := migrate(db); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// Close закрывает соединение с БД.
|
|
func (s *Store) Close() error {
|
|
if err := s.db.Close(); err != nil {
|
|
return fmt.Errorf("close sqlite: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
|
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
|
func dsn(path string) string {
|
|
q := url.Values{}
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "foreign_keys(on)")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
func migrate(db *sqlx.DB) error {
|
|
goose.SetBaseFS(migrationsFS)
|
|
goose.SetLogger(goose.NopLogger())
|
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
|
return fmt.Errorf("goose dialect: %w", err)
|
|
}
|
|
if err := goose.Up(db.DB, "migrations"); err != nil {
|
|
return fmt.Errorf("goose up: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Now — единая точка генерации времени: UTC, секундная точность.
|
|
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
|
// сортировка TEXT совпадает с хронологией.
|
|
func Now() time.Time { return time.Now().UTC().Truncate(time.Second) }
|
|
|
|
// FormatTime приводит время к каноническому виду хранения: RFC 3339, UTC.
|
|
func FormatTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
|
|
|
// ParseTime разбирает метку времени из БД.
|
|
func ParseTime(s string) (time.Time, error) {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("parse time %q: %w", s, err)
|
|
}
|
|
return t.UTC(), nil
|
|
}
|