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 }