Files
Cyrene_v2/internal/services/backthink/store.go
T

182 lines
5.3 KiB
Go

package backthink
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// defaultListLimit is the number of most-recent records returned by List when the
// caller passes a non-positive limit.
const defaultListLimit = 50
// DDL for the thinking record store. at/created_at follow the project timestamp
// convention (RFC3339 with a timezone); trigger and conclusion are the free-text
// fields the panel renders; the push_* columns carry a decomposed PushResult so
// List need not parse a blob. The primary key is the project-standard auto id.
const createRecordsSQL = `
CREATE TABLE IF NOT EXISTS backthink_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger TEXT NOT NULL,
at TEXT NOT NULL,
conclusion TEXT NOT NULL,
should_push INTEGER NOT NULL,
push_pushed INTEGER NOT NULL DEFAULT 0,
push_route TEXT NOT NULL,
push_detail TEXT NOT NULL DEFAULT '',
push_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`
const createRecordsIndexSQL = `
CREATE INDEX IF NOT EXISTS idx_backthink_records_at ON backthink_records(at)`
const insertRecordSQL = `
INSERT INTO backthink_records
(trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
const selectRecordsSQL = `
SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error
FROM backthink_records
ORDER BY id DESC
LIMIT ?`
// Record is one persisted thinking pass (design/12 §4), returned by Store.List
// and the service's List. Trigger is the source subsystem label, At the decision
// timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision
// and PushResult how (or whether) the push went out.
type Record struct {
ID int64
Trigger string
At time.Time
Conclusion string
ShouldPush bool
PushResult PushResult
}
// Store persists thinking records in SQLite. It creates its table and index in
// NewStore so the module is self-contained (no migration bookkeeping), mirrors
// how channel/session creates its own table. It is concurrency-safe by virtue of
// the underlying *sql.DB.
type Store struct {
db *sql.DB
log *slog.Logger
}
// NewStore builds a Store, ensuring the backthink_records table and index exist.
// It returns ErrNilDB when db is nil. ctx is used for the DDL.
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(baseLog(log), "services/backthink")
if _, err := db.ExecContext(ctx, createRecordsSQL); err != nil {
return nil, fmt.Errorf("backthink: ensure backthink_records: %w", err)
}
if _, err := db.ExecContext(ctx, createRecordsIndexSQL); err != nil {
return nil, fmt.Errorf("backthink: ensure backthink_records index: %w", err)
}
return &Store{db: db, log: log}, nil
}
// Append persists r and returns the new record id.
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
res, err := s.db.ExecContext(ctx, insertRecordSQL,
r.Trigger,
r.At.UTC().Format(time.RFC3339Nano),
r.Conclusion,
boolInt(r.ShouldPush),
boolInt(r.PushResult.Pushed),
string(r.PushResult.Route),
r.PushResult.Detail,
r.PushResult.Err,
)
if err != nil {
return 0, fmt.Errorf("backthink: insert record: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("backthink: record id: %w", err)
}
if s.log != nil {
s.log.Debug("backthink record appended", "id", id, "trigger", r.Trigger, "should_push", r.ShouldPush, "route", r.PushResult.Route)
}
return id, nil
}
// List returns the most recent records newest-first. A non-positive limit selects
// defaultListLimit.
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
if limit <= 0 {
limit = defaultListLimit
}
if err := ctx.Err(); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, selectRecordsSQL, limit)
if err != nil {
return nil, fmt.Errorf("backthink: list records: %w", err)
}
defer rows.Close()
records := make([]Record, 0, limit)
for rows.Next() {
var (
r Record
at string
route string
pushed int
shouldPush int
)
if err := rows.Scan(
&r.ID,
&r.Trigger,
&at,
&r.Conclusion,
&shouldPush,
&pushed,
&route,
&r.PushResult.Detail,
&r.PushResult.Err,
); err != nil {
return nil, fmt.Errorf("backthink: scan record: %w", err)
}
r.ShouldPush = intBool(shouldPush)
r.PushResult.Pushed = intBool(pushed)
r.PushResult.Route = Route(route)
r.At = parseTime(at)
records = append(records, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("backthink: iterate records: %w", err)
}
return records, nil
}
// boolInt maps a bool to the SQLite INTEGER 1/0 representation.
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// intBool maps a SQLite INTEGER to a bool.
func intBool(n int) bool { return n != 0 }
// parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to
// the zero time so a malformed row never breaks a listing.
func parseTime(s string) time.Time {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}