Skip to content
quarry Artifact [afb835bf7b]
Login

Artifact [afb835bf7b]

Artifact afb835bf7b74f350caf826425e63adb6b195716ea6105607ce63c523239bdbea:


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)
	}
}