182 lines
5.3 KiB
Go
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{}
|
|
}
|