Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
178 lines
4.6 KiB
Go
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
|
|
}
|