Files
AetherForge/server/internal/db/strain_hospice.go
AetherForge d605ef4adb
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Strain Hospice for graceful low-win strain retirement.
Archive failed epidemiology strains to museum hospice with SQLite persistence, operator/AI/court triggers, breeding and graft guards, topology museum nodes, and Seer plus oath ledger accountability.
2026-06-07 09:26:39 -07:00

178 lines
4.6 KiB
Go

package db
import (
"database/sql"
"errors"
"strings"
"time"
)
// StrainHospiceRecord is a retired spread strain preserved for museum read-only lineage.
type StrainHospiceRecord struct {
StrainID string
RetiredAt time.Time
RetiredBy string
Reason string
CardJSON string
}
func (d *Database) ensureStrainHospiceTable() error {
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS strain_hospice (
strain_id TEXT PRIMARY KEY,
retired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
retired_by TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
card_json TEXT NOT NULL DEFAULT '{}'
)`)
if err != nil {
return err
}
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_strain_hospice_retired_at ON strain_hospice(retired_at)`)
return nil
}
// RetireStrain archives a strain to hospice (idempotent).
func (d *Database) RetireStrain(strainID, retiredBy, reason, cardJSON string) error {
if d == nil {
return errors.New("database unavailable")
}
strainID = normalizeStrainID(strainID)
if strainID == "" {
return errors.New("strain_id required")
}
retiredBy = strings.TrimSpace(retiredBy)
if retiredBy == "" {
return errors.New("retired_by required")
}
if strings.TrimSpace(cardJSON) == "" {
cardJSON = "{}"
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := d.Exec(
`INSERT INTO strain_hospice (strain_id, retired_at, retired_by, reason, card_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(strain_id) DO NOTHING`,
strainID, now, retiredBy, strings.TrimSpace(reason), cardJSON,
)
return err
}
// IsStrainInHospice reports whether a strain is archived.
func (d *Database) IsStrainInHospice(strainID string) (bool, error) {
if d == nil {
return false, nil
}
strainID = normalizeStrainID(strainID)
if strainID == "" {
return false, nil
}
var n int
err := d.QueryRow(`SELECT 1 FROM strain_hospice WHERE strain_id = ? LIMIT 1`, strainID).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// GetStrainHospice returns one hospice row.
func (d *Database) GetStrainHospice(strainID string) (*StrainHospiceRecord, error) {
strainID = normalizeStrainID(strainID)
if strainID == "" {
return nil, sql.ErrNoRows
}
row := d.QueryRow(
`SELECT strain_id, retired_at, retired_by, reason, card_json FROM strain_hospice WHERE strain_id = ?`,
strainID,
)
return scanStrainHospice(row)
}
// ListStrainHospice returns retired strains newest first.
func (d *Database) ListStrainHospice(limit int) ([]StrainHospiceRecord, error) {
if limit <= 0 {
limit = 100
}
rows, err := d.Query(
`SELECT strain_id, retired_at, retired_by, reason, card_json
FROM strain_hospice ORDER BY retired_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StrainHospiceRecord
for rows.Next() {
rec, err := scanStrainHospice(rows)
if err != nil {
return nil, err
}
out = append(out, *rec)
}
return out, rows.Err()
}
// HospiceStrainSet returns a lookup set of retired strain ids.
func (d *Database) HospiceStrainSet() (map[string]bool, error) {
rows, err := d.ListStrainHospice(500)
if err != nil {
return nil, err
}
out := make(map[string]bool, len(rows))
for _, r := range rows {
out[r.StrainID] = true
}
return out, nil
}
func normalizeStrainID(strain string) string {
s := strings.TrimSpace(strings.ToLower(strain))
if s == "" {
return ""
}
if !strings.HasPrefix(s, "#") && len(s) == 6 {
s = "#" + s
}
return s
}
type strainHospiceScanner interface {
Scan(dest ...interface{}) error
}
func scanStrainHospice(row strainHospiceScanner) (*StrainHospiceRecord, error) {
var rec StrainHospiceRecord
var retiredAt string
if err := row.Scan(&rec.StrainID, &retiredAt, &rec.RetiredBy, &rec.Reason, &rec.CardJSON); err != nil {
return nil, err
}
rec.StrainID = normalizeStrainID(rec.StrainID)
rec.RetiredAt = parseSQLiteTime(retiredAt)
return &rec, nil
}
// CardJSONForStrain finds lineage card JSON for a strain from strain_cards or hospice.
func (d *Database) CardJSONForStrain(strainID string) (string, error) {
strainID = normalizeStrainID(strainID)
if strainID == "" {
return "{}", nil
}
if rec, err := d.GetStrainHospice(strainID); err == nil && rec != nil && strings.TrimSpace(rec.CardJSON) != "" {
return rec.CardJSON, nil
}
row := d.QueryRow(
`SELECT card_json FROM strain_cards WHERE LOWER(spread_strain) = ? ORDER BY peak_hashrate DESC LIMIT 1`,
strainID,
)
var cardJSON string
if err := row.Scan(&cardJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "{}", nil
}
return "", err
}
if strings.TrimSpace(cardJSON) == "" {
return "{}", nil
}
return cardJSON, nil
}