package index
import (
"strings"
"testing"
)
// Reset is the remedy the schema-version stamp points at, and a version
// bump means THE SHAPE CHANGED — so a Reset that only deletes rows is a
// remedy that cannot perform the one repair it exists for. That lie
// caused the 2026-08-19 outage: a bump wiped every index, the refold
// hit "no such column", and the pipeline ground against a table Reset
// had emptied but never widened.
//
// The contract pinned here: whatever shape the file is in, Reset makes
// it THIS code's shape, ready to refold.
func TestResetRepairsShape(t *testing.T) {
ix := openIndex(t)
id := strings.Repeat("ab", 20)
foldTicket(t, ix, "rs1", id, "2026-01-01T10:00:00",
"title", "before", "status", "Open", "priority", "High")
// The file now carries an OLDER shape, the way any index on disk
// does the moment code adds a column.
if _, err := ix.db.Exec(`ALTER TABLE ticket DROP COLUMN priority`); err != nil {
t.Fatal(err)
}
if err := ix.Reset(); err != nil {
t.Fatal(err)
}
// Refolding into the reset index must land, column included.
foldTicket(t, ix, "rs2", id, "2026-01-02T10:00:00",
"title", "after", "status", "Open", "priority", "Immediate")
list, _, err := ix.TicketSummaries(10, "", false)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Priority != "Immediate" {
t.Fatalf("refold after Reset = %+v, want the ticket with its priority", list)
}
// And search still works: the fts table is derived state like any
// other, and a Reset that leaves a stale corpus behind serves ghost
// results for content the index no longer holds.
if n, err := ix.SearchCount("before", "ticket"); err != nil || n != 0 {
t.Fatalf("the pre-reset corpus survived (n=%d err=%v)", n, err)
}
if n, err := ix.SearchCount("after", "ticket"); err != nil || n != 1 {
t.Fatalf("the refolded ticket is not searchable (n=%d err=%v)", n, err)
}
}
// The search index is a cache over search_doc, and quarry adopts zit's
// cache contract: verified on open, and any mismatch is a MISS that
// rebuilds — never an error a reader sees. The fleet hit this live:
// fts shadow state that had drifted somewhere in an index's history
// (unsynced deletes reuse rowids) made every search on that repo a 500
// with "database disk image is malformed", while the content table
// beside it was perfectly healthy.
func TestOpenHealsDriftedSearchIndex(t *testing.T) {
dir := t.TempDir() + "/idx.db"
ix, err := Open(dir)
if err != nil {
t.Fatal(err)
}
foldTicket(t, ix, "fh1", strings.Repeat("aa", 20), "2026-01-01T10:00:00",
"title", "findable pelican", "status", "Open")
// Desync the way history did: a delete that bypasses the sync
// triggers, then a reinsert that reuses the rowid for other text.
for _, stmt := range []string{
`DROP TRIGGER search_doc_ad`,
`DELETE FROM search_doc`,
`CREATE TRIGGER search_doc_ad AFTER DELETE ON search_doc BEGIN
INSERT INTO search_fts (search_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
END`,
} {
if _, err := ix.db.Exec(stmt); err != nil {
t.Fatal(err)
}
}
ix.Close()
ix2, err := Open(dir)
if err != nil {
t.Fatalf("Open refused a repairable index: %v", err)
}
defer ix2.Close()
foldTicket(t, ix2, "fh2", strings.Repeat("bb", 20), "2026-01-02T10:00:00",
"title", "findable flamingo", "status", "Open")
hits, err := ix2.Search("findable", "", 10)
if err != nil {
t.Fatalf("search after heal: %v", err)
}
if len(hits) != 1 {
t.Fatalf("got %d hits, want exactly the refolded doc: %+v", len(hits), hits)
}
// The ghost must be gone, not merely off the first page.
if n, _ := ix2.SearchCount("pelican", ""); n != 0 {
t.Fatalf("a deleted document still matches after the heal: %d", n)
}
}